diff --git a/src/components/collaboration/VideoConference.tsx b/src/components/collaboration/VideoConference.tsx index 09e3eb7b..a7a8a68b 100644 --- a/src/components/collaboration/VideoConference.tsx +++ b/src/components/collaboration/VideoConference.tsx @@ -1,14 +1,20 @@ 'use client'; import { useEffect, useRef, useState } from 'react'; -import { Mic, Video, Monitor, Phone, VideoOff, MicOff } from 'lucide-react'; +import { Mic, Video, Monitor, Phone, VideoOff, MicOff, ShieldAlert, AlertTriangle } from 'lucide-react'; import { io, type Socket } from 'socket.io-client'; import type { CollaborationUser } from '../../hooks/useCollaboration'; +import { useFraudDetection } from '../../hooks/useFraudDetection'; +import { fraudDetectionService, FraudDetectionService } from '../../services/fraud-detection'; +import type { FraudDetectionResult } from '../../services/fraud-detection'; interface VideoConferenceProps { roomId: string; user: CollaborationUser; websocketUrl?: string; + fraudService?: FraudDetectionService; + isHost?: boolean; + hostUserId?: string; } type SignalingOffer = { @@ -29,7 +35,14 @@ type IceCandidatePayload = { userId: string; }; -export function VideoConference({ roomId, user, websocketUrl }: VideoConferenceProps) { +export function VideoConference({ + roomId, + user, + websocketUrl, + fraudService = fraudDetectionService, + isHost = false, + hostUserId, +}: VideoConferenceProps) { const localVideoRef = useRef(null); const remoteVideoRef = useRef(null); const pcRef = useRef(null); @@ -42,14 +55,32 @@ export function VideoConference({ roomId, user, websocketUrl }: VideoConferenceP const [microphoneEnabled, setMicrophoneEnabled] = useState(true); const [sharingScreen, setSharingScreen] = useState(false); const [status, setStatus] = useState('Idle'); + const [fraudWarning, setFraudWarning] = useState(null); - const signalingUrl = websocketUrl || process.env.NEXT_PUBLIC_WEBSOCKET_URL || 'http://localhost:3001'; + const fraud = useFraudDetection(fraudService, { + user, + roomId, + isHost, + hostUserId, + }); + + const signalingUrl = + websocketUrl || + process.env.NEXT_PUBLIC_WEBSOCKET_URL || + 'http://localhost:3001'; useEffect(() => { if (typeof window === 'undefined') { return undefined; } + const check = fraud.checkJoin(); + if (check.blocked) { + setStatus('Access blocked by fraud detection'); + setFraudWarning('Multiple active connections detected'); + return undefined; + } + const socket = io(signalingUrl, { autoConnect: true, transports: ['websocket'], @@ -57,7 +88,11 @@ export function VideoConference({ roomId, user, websocketUrl }: VideoConferenceP socketRef.current = socket; socket.on('connect', () => { - socket.emit('join-room', { roomId, userId: user.id, userName: user.name }); + socket.emit('join-room', { + roomId, + userId: user.id, + userName: user.name, + }); setStatus('Connected to signaling'); }); @@ -65,7 +100,9 @@ export function VideoConference({ roomId, user, websocketUrl }: VideoConferenceP if (userId === user.id) return; await createPeerConnection(); if (!pcRef.current) return; - await pcRef.current.setRemoteDescription(new RTCSessionDescription(offer)); + await pcRef.current.setRemoteDescription( + new RTCSessionDescription(offer), + ); const answer = await pcRef.current.createAnswer(); await pcRef.current.setLocalDescription(answer); socket.emit('webrtc-answer', { roomId, answer, userId: user.id }); @@ -76,7 +113,9 @@ export function VideoConference({ roomId, user, websocketUrl }: VideoConferenceP socket.on('webrtc-answer', async ({ answer, userId }: SignalingAnswer) => { if (userId === user.id) return; if (!pcRef.current) return; - await pcRef.current.setRemoteDescription(new RTCSessionDescription(answer)); + await pcRef.current.setRemoteDescription( + new RTCSessionDescription(answer), + ); setCallActive(true); setStatus('Call established'); }); @@ -91,6 +130,7 @@ export function VideoConference({ roomId, user, websocketUrl }: VideoConferenceP }); return () => { + fraud.checkLeave(); socket.disconnect(); socketRef.current = null; }; @@ -132,7 +172,10 @@ export function VideoConference({ roomId, user, websocketUrl }: VideoConferenceP }; peerConnection.onconnectionstatechange = () => { - if (peerConnection.connectionState === 'disconnected' || peerConnection.connectionState === 'failed') { + if ( + peerConnection.connectionState === 'disconnected' || + peerConnection.connectionState === 'failed' + ) { setStatus('Remote connection lost'); } }; @@ -143,7 +186,10 @@ export function VideoConference({ roomId, user, websocketUrl }: VideoConferenceP const startLocalStream = async () => { try { - const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true }); + const stream = await navigator.mediaDevices.getUserMedia({ + video: true, + audio: true, + }); setLocalStream(stream); stream.getAudioTracks().forEach((track) => { track.enabled = microphoneEnabled; @@ -166,11 +212,30 @@ export function VideoConference({ roomId, user, websocketUrl }: VideoConferenceP return; } + const check: FraudDetectionResult = fraud.checkStartCall(); + if (check.blocked) { + setStatus('Call blocked by fraud detection'); + setFraudWarning('Suspicious activity detected. Please try again later.'); + return; + } + if (check.isSuspicious) { + setFraudWarning('Unusual activity detected. Your actions are being monitored.'); + } + + const bombingCheck: FraudDetectionResult = fraud.checkMeetingBombing(); + if (bombingCheck.blocked) { + setStatus('Meeting bombing attempt detected'); + setFraudWarning('Too many rapid join attempts detected'); + return; + } + const stream = await startLocalStream(); if (!stream) return; const peerConnection = await createPeerConnection(); - stream.getTracks().forEach((track) => peerConnection.addTrack(track, stream)); + stream + .getTracks() + .forEach((track) => peerConnection.addTrack(track, stream)); const offer = await peerConnection.createOffer(); await peerConnection.setLocalDescription(offer); @@ -215,12 +280,24 @@ export function VideoConference({ roomId, user, websocketUrl }: VideoConferenceP }); setLocalStream(stream); setSharingScreen(false); - socketRef.current?.emit('screen-share', { roomId, userId: user.id, sharing: false }); + fraud.checkScreenShare(false); + socketRef.current?.emit('screen-share', { + roomId, + userId: user.id, + sharing: false, + }); return; } + const check = fraud.checkScreenShare(true); + if (check.isSuspicious) { + setFraudWarning('Screen share abuse detected. Rate limited.'); + } + try { - const displayStream = await navigator.mediaDevices.getDisplayMedia({ video: true }); + const displayStream = await navigator.mediaDevices.getDisplayMedia({ + video: true, + }); const screenTrack = displayStream.getVideoTracks()[0]; const peerConnection = await createPeerConnection(); peerConnection.getSenders().forEach((sender) => { @@ -229,7 +306,11 @@ export function VideoConference({ roomId, user, websocketUrl }: VideoConferenceP } }); setSharingScreen(true); - socketRef.current?.emit('screen-share', { roomId, userId: user.id, sharing: true }); + socketRef.current?.emit('screen-share', { + roomId, + userId: user.id, + sharing: true, + }); screenTrack.onended = () => { setSharingScreen(false); if (localStream) { @@ -257,22 +338,69 @@ export function VideoConference({ roomId, user, websocketUrl }: VideoConferenceP
-

Video conference

-

Peer-to-peer WebRTC audio/video with screen sharing and signaling.

+
+

+ Video conference +

+ {fraud.fraudScore > 0 && ( + = 50 + ? 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-400' + : fraud.fraudScore >= 20 + ? 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-400' + : 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-400' + }`} + title={`Fraud score: ${fraud.fraudScore}`} + > + + )} +
+

+ Peer-to-peer WebRTC audio/video with screen sharing and signaling. +

+
+
+ {fraudWarning && ( +
+
+ )} +
+ {status} +
-
{status}
-
-
@@ -280,7 +408,8 @@ export function VideoConference({ roomId, user, websocketUrl }: VideoConferenceP @@ -292,10 +421,28 @@ export function VideoConference({ roomId, user, websocketUrl }: VideoConferenceP End call
+ + {fraud.accessCheck && !fraud.accessCheck.allowed && ( +
+
+
+

+ {fraud.accessCheck.reason || + 'Access to this conference is restricted.'} +

+
+ )}
-
Controls
+
+ Controls +
- {microphoneEnabled ? 'Microphone active' : 'Microphone muted'} + + {microphoneEnabled ? 'Microphone active' : 'Microphone muted'} +
+
+ + + {fraud.fraudScore === 0 + ? 'No suspicious activity' + : `Fraud score: ${fraud.fraudScore}`} + +
diff --git a/src/components/leaderboard/LeaderboardConference.tsx b/src/components/leaderboard/LeaderboardConference.tsx index 27db6171..d46757f2 100644 --- a/src/components/leaderboard/LeaderboardConference.tsx +++ b/src/components/leaderboard/LeaderboardConference.tsx @@ -1,8 +1,20 @@ 'use client'; -import { useState } from 'react'; -import { Trophy, Medal, Crown, Users, Plus, Trash2, Video } from 'lucide-react'; +import { useState, useCallback } from 'react'; +import { + Trophy, + Medal, + Crown, + Users, + Plus, + Trash2, + Video, + ShieldAlert, + Lock, +} from 'lucide-react'; import { VideoConference } from '@/components/collaboration/VideoConference'; +import { fraudDetectionService, FraudDetectionService } from '@/services/fraud-detection'; +import type { ConferenceAccessCheck } from '@/services/fraud-detection'; export interface LeaderboardEntry { id: string; @@ -17,6 +29,8 @@ export interface Conference { name: string; roomId: string; participants: number; + hostUserId?: string; + locked?: boolean; } interface LeaderboardConferenceProps { @@ -24,6 +38,7 @@ interface LeaderboardConferenceProps { conferences?: Conference[]; currentUserId?: string; currentUserName?: string; + fraudService?: FraudDetectionService; } const RANK_ICONS = [ @@ -45,11 +60,13 @@ export function LeaderboardConference({ conferences: initialConferences = [], currentUserId = 'guest', currentUserName = 'Guest', + fraudService = fraudDetectionService, }: LeaderboardConferenceProps) { const [conferences, setConferences] = useState(initialConferences); const [activeConference, setActiveConference] = useState(null); const [newConferenceName, setNewConferenceName] = useState(''); const [showCreateForm, setShowCreateForm] = useState(false); + const [accessError, setAccessError] = useState(null); const sorted = [...entries].sort((a, b) => a.rank - b.rank); @@ -61,6 +78,8 @@ export function LeaderboardConference({ name, roomId: `room-${Date.now()}`, participants: 0, + hostUserId: currentUserId, + locked: false, }; setConferences((prev) => [...prev, conf]); setNewConferenceName(''); @@ -68,10 +87,50 @@ export function LeaderboardConference({ }; const handleDeleteConference = (id: string) => { - if (activeConference?.id === id) setActiveConference(null); + if (activeConference?.id === id) { + setActiveConference(null); + } setConferences((prev) => prev.filter((c) => c.id !== id)); }; + const handleJoinConference = useCallback( + (conf: Conference) => { + setAccessError(null); + + if (conf.locked) { + setAccessError('This conference is locked by the host.'); + return; + } + + const accessCheck: ConferenceAccessCheck = + fraudService.checkConferenceAccess( + currentUserId, + conf.roomId, + conf.hostUserId === currentUserId, + conf.hostUserId, + ); + + if (!accessCheck.allowed) { + setAccessError( + accessCheck.reason || + 'Access denied by fraud detection.', + ); + return; + } + + if (accessCheck.requiresVerification) { + setAccessError( + accessCheck.reason || 'Access granted with monitoring.', + ); + } + + setActiveConference( + activeConference?.id === conf.id ? null : conf, + ); + }, + [activeConference, currentUserId, fraudService], + ); + return (
{/* Leaderboard */} @@ -81,7 +140,9 @@ export function LeaderboardConference({ >
    @@ -90,9 +151,16 @@ export function LeaderboardConference({ key={entry.id} className="flex items-center gap-3 rounded-2xl px-4 py-3 bg-slate-50 dark:bg-slate-900 border border-slate-100 dark:border-slate-800" > - - {entry.rank <= 3 ? RANK_ICONS[entry.rank - 1] : ( - {entry.rank} + + {entry.rank <= 3 ? ( + RANK_ICONS[entry.rank - 1] + ) : ( + + {entry.rank} + )} @@ -104,7 +172,10 @@ export function LeaderboardConference({ {entry.name} - + {entry.score.toLocaleString()} pts @@ -120,7 +191,9 @@ export function LeaderboardConference({
    )} + {accessError && ( +
    +
    + )} + {conferences.length === 0 ? (

    No conferences yet. Create one to get started. @@ -166,13 +258,30 @@ export function LeaderboardConference({ key={conf.id} className="flex items-center gap-3 rounded-2xl px-4 py-3 bg-slate-50 dark:bg-slate-900 border border-slate-100 dark:border-slate-800" > -

diff --git a/src/hooks/useCollaboration.ts b/src/hooks/useCollaboration.ts index c6b1af90..72521240 100644 --- a/src/hooks/useCollaboration.ts +++ b/src/hooks/useCollaboration.ts @@ -15,6 +15,7 @@ export type CollaborationUser = { color: string; isSharingScreen?: boolean; isActive?: boolean; + isHost?: boolean; cursor?: CursorPosition; }; diff --git a/src/hooks/useFraudDetection.ts b/src/hooks/useFraudDetection.ts new file mode 100644 index 00000000..aed22fb3 --- /dev/null +++ b/src/hooks/useFraudDetection.ts @@ -0,0 +1,144 @@ +'use client'; + +import { useCallback, useRef, useState } from 'react'; +import { FraudDetectionService } from '@/services/fraud-detection'; +import type { + UserActionContext, + FraudDetectionResult, + ConferenceAccessCheck, +} from '@/services/fraud-detection'; +import type { CollaborationUser } from './useCollaboration'; + +interface UseFraudDetectionOptions { + user: CollaborationUser; + roomId: string; + isHost?: boolean; + hostUserId?: string; +} + +interface UseFraudDetectionReturn { + fraudScore: number; + fraudEvents: FraudDetectionResult['events']; + isBlocked: boolean; + lastCheck: FraudDetectionResult | null; + accessCheck: ConferenceAccessCheck | null; + checkJoin: () => FraudDetectionResult; + checkLeave: () => FraudDetectionResult; + checkStartCall: () => FraudDetectionResult; + checkScreenShare: (enabled: boolean) => FraudDetectionResult; + checkAccess: () => ConferenceAccessCheck; + checkMeetingBombing: () => FraudDetectionResult; + resetScore: () => void; +} + +export function useFraudDetection( + service: FraudDetectionService, + options: UseFraudDetectionOptions, +): UseFraudDetectionReturn { + const { user, roomId, isHost = false, hostUserId } = options; + const [fraudScore, setFraudScore] = useState(0); + const [fraudEvents, setFraudEvents] = useState([]); + const [isBlocked, setIsBlocked] = useState(false); + const [lastCheck, setLastCheck] = useState(null); + const [accessCheck, setAccessCheck] = useState(null); + + const contextRef = useRef({ + userId: user.id, + userName: user.name, + roomId, + timestamp: Date.now(), + }); + + const updateContext = useCallback(() => { + contextRef.current = { + userId: user.id, + userName: user.name, + roomId, + timestamp: Date.now(), + }; + }, [user.id, user.name, roomId]); + + const checkJoin = useCallback((): FraudDetectionResult => { + updateContext(); + const result = service.checkJoinMeeting(contextRef.current); + setFraudScore(service.getUserScore(user.id)); + setFraudEvents(service.getEvents(user.id)); + setLastCheck(result); + return result; + }, [service, updateContext, user.id]); + + const checkLeave = useCallback((): FraudDetectionResult => { + updateContext(); + const result = service.checkLeaveMeeting(contextRef.current); + setFraudScore(service.getUserScore(user.id)); + setFraudEvents(service.getEvents(user.id)); + setLastCheck(result); + return result; + }, [service, updateContext, user.id]); + + const checkStartCall = useCallback((): FraudDetectionResult => { + updateContext(); + const result = service.checkStartCall(contextRef.current); + setFraudScore(service.getUserScore(user.id)); + setFraudEvents(service.getEvents(user.id)); + setIsBlocked(result.blocked); + setLastCheck(result); + return result; + }, [service, updateContext, user.id]); + + const checkScreenShare = useCallback( + (enabled: boolean): FraudDetectionResult => { + updateContext(); + const result = service.checkScreenShare(contextRef.current, enabled); + setFraudScore(service.getUserScore(user.id)); + setFraudEvents(service.getEvents(user.id)); + setLastCheck(result); + return result; + }, + [service, updateContext, user.id], + ); + + const checkAccess = useCallback((): ConferenceAccessCheck => { + const result = service.checkConferenceAccess( + user.id, + roomId, + isHost, + hostUserId, + ); + setAccessCheck(result); + return result; + }, [service, user.id, roomId, isHost, hostUserId]); + + const checkMeetingBombing = useCallback((): FraudDetectionResult => { + updateContext(); + const result = service.checkMeetingBombing(contextRef.current); + setFraudScore(service.getUserScore(user.id)); + setFraudEvents(service.getEvents(user.id)); + setLastCheck(result); + return result; + }, [service, updateContext, user.id]); + + const resetScore = useCallback(() => { + service.resetUserScore(user.id); + setFraudScore(0); + setFraudEvents([]); + setIsBlocked(false); + setLastCheck(null); + setAccessCheck(null); + }, [service, user.id]); + + return { + fraudScore, + fraudEvents, + isBlocked, + lastCheck, + accessCheck, + checkJoin, + checkLeave, + checkStartCall, + checkScreenShare, + checkAccess, + checkMeetingBombing, + resetScore, + }; +} diff --git a/src/services/fraud-detection/__tests__/FraudDetectionService.test.ts b/src/services/fraud-detection/__tests__/FraudDetectionService.test.ts new file mode 100644 index 00000000..3d25966e --- /dev/null +++ b/src/services/fraud-detection/__tests__/FraudDetectionService.test.ts @@ -0,0 +1,407 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { FraudDetectionService } from '../index'; +import type { UserActionContext } from '../types'; + +// Use fake timers for predictable time-based tests +beforeEach(() => { + vi.useFakeTimers(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +function createContext(overrides: Partial = {}): UserActionContext { + return { + userId: 'user-1', + userName: 'Test User', + roomId: 'room-1', + timestamp: Date.now(), + ...overrides, + }; +} + +describe('FraudDetectionService', () => { + let service: FraudDetectionService; + + beforeEach(() => { + service = new FraudDetectionService(); + }); + + afterEach(() => { + service.clearEvents(); + }); + + describe('checkJoinMeeting', () => { + it('allows joining when no suspicious activity exists', () => { + const result = service.checkJoinMeeting(createContext()); + expect(result.blocked).toBe(false); + expect(result.isSuspicious).toBe(false); + expect(result.score).toBe(0); + expect(result.events).toHaveLength(0); + }); + + it('flags rapid join attempts', () => { + const ctx = createContext(); + + // Join 5 times (max allowed is 5 per minute in default config) + for (let i = 0; i < 5; i++) { + vi.advanceTimersByTime(100); + service.checkJoinMeeting({ ...ctx, timestamp: Date.now() }); + } + + // 6th join should trigger fraud detection + const result = service.checkJoinMeeting({ + ...ctx, + timestamp: Date.now(), + }); + expect(result.isSuspicious).toBe(true); + expect(result.events.some((e) => e.category === 'RAPID_JOIN_LEAVE')).toBe(true); + expect(result.score).toBeGreaterThan(0); + }); + + it('blocks when exceeding max connections per user', () => { + const ctx = createContext(); + + // Simulate 3 concurrent connections (max is 2) + for (let i = 0; i < 3; i++) { + service.checkJoinMeeting({ + ...ctx, + roomId: `room-${i}`, + timestamp: Date.now(), + }); + } + + const result = service.checkJoinMeeting({ + ...ctx, + roomId: 'room-3', + timestamp: Date.now(), + }); + expect(result.blocked).toBe(true); + expect(result.events.some((e) => e.category === 'MULTIPLE_CONNECTIONS')).toBe(true); + }); + + it('does not block when config.blockOnCriticalThreats is false', () => { + service = new FraudDetectionService({ blockOnCriticalThreats: false }); + const ctx = createContext(); + + for (let i = 0; i < 3; i++) { + service.checkJoinMeeting({ + ...ctx, + roomId: `room-${i}`, + timestamp: Date.now(), + }); + } + + const result = service.checkJoinMeeting({ + ...ctx, + roomId: 'room-3', + timestamp: Date.now(), + }); + expect(result.blocked).toBe(false); + expect(result.isSuspicious).toBe(true); + }); + }); + + describe('checkLeaveMeeting', () => { + it('allows leaving a meeting without flags', () => { + service.checkJoinMeeting(createContext()); + const result = service.checkLeaveMeeting(createContext()); + expect(result.blocked).toBe(false); + expect(result.isSuspicious).toBe(false); + }); + + it('flags rapid leave attempts', () => { + const ctx = createContext(); + + for (let i = 0; i < 5; i++) { + vi.advanceTimersByTime(100); + service.checkJoinMeeting({ ...ctx, timestamp: Date.now() }); + service.checkLeaveMeeting({ ...ctx, timestamp: Date.now() }); + } + + const result = service.checkLeaveMeeting({ + ...ctx, + timestamp: Date.now(), + }); + expect(result.isSuspicious).toBe(true); + expect(result.events.some((e) => e.category === 'RAPID_JOIN_LEAVE')).toBe(true); + }); + + it('clears connection record on leave', () => { + const ctx = createContext(); + service.checkJoinMeeting(ctx); + const result = service.checkLeaveMeeting(ctx); + expect(result.blocked).toBe(false); + }); + }); + + describe('checkScreenShare', () => { + it('allows screen share toggling within limits', () => { + const ctx = createContext(); + for (let i = 0; i < 3; i++) { + vi.advanceTimersByTime(1000); + const result = service.checkScreenShare( + { ...ctx, timestamp: Date.now() }, + true, + ); + expect(result.blocked).toBe(false); + expect(result.events).toHaveLength(0); + } + }); + + it('flags rapid screen share toggles', () => { + const ctx = createContext(); + + // Toggle 5 times rapidly (max is 4 per minute) + for (let i = 0; i < 5; i++) { + vi.advanceTimersByTime(100); + service.checkScreenShare({ ...ctx, timestamp: Date.now() }, true); + } + + const result = service.checkScreenShare( + { ...ctx, timestamp: Date.now() }, + true, + ); + expect(result.isSuspicious).toBe(true); + expect(result.events.some((e) => e.category === 'SCREEN_SHARE_ABUSE')).toBe(true); + }); + }); + + describe('checkStartCall', () => { + it('allows starting calls within limits', () => { + const ctx = createContext(); + for (let i = 0; i < 3; i++) { + vi.advanceTimersByTime(1000); + const result = service.checkStartCall({ + ...ctx, + timestamp: Date.now(), + }); + expect(result.blocked).toBe(false); + } + }); + + it('flags rapid call attempts', () => { + const ctx = createContext(); + + for (let i = 0; i < 4; i++) { + vi.advanceTimersByTime(100); + service.checkStartCall({ ...ctx, timestamp: Date.now() }); + } + + const result = service.checkStartCall({ + ...ctx, + timestamp: Date.now(), + }); + expect(result.isSuspicious).toBe(true); + expect(result.events.some((e) => e.category === 'ACTION_ABUSE')).toBe(true); + }); + + it('blocks call when user score exceeds threshold', () => { + const ctx = createContext(); + + // Accumulate score by triggering multiple fraud events + for (let i = 0; i < 6; i++) { + vi.advanceTimersByTime(100); + service.checkStartCall({ ...ctx, timestamp: Date.now() }); + } + + // Additional call attempts increase score + for (let i = 0; i < 4; i++) { + vi.advanceTimersByTime(100); + const result = service.checkStartCall({ + ...ctx, + timestamp: Date.now(), + }); + if (result.blocked) { + expect( + result.events.some((e) => e.category === 'SUSPICIOUS_IDENTITY'), + ).toBe(true); + return; + } + } + + // Should have been blocked by now + const finalResult = service.checkStartCall({ + ...ctx, + timestamp: Date.now(), + }); + expect(finalResult.blocked).toBe(true); + }); + }); + + describe('checkConferenceAccess', () => { + it('allows host access', () => { + const result = service.checkConferenceAccess('user-1', 'room-1', true); + expect(result.allowed).toBe(true); + expect(result.reason).toBeUndefined(); + }); + + it('allows guest access when user has clean record', () => { + const result = service.checkConferenceAccess('user-2', 'room-1', false); + expect(result.allowed).toBe(true); + }); + + it('restricts access when user score is high', () => { + const ctx = createContext(); + + // Drive up the score + for (let i = 0; i < 10; i++) { + vi.advanceTimersByTime(100); + service.checkStartCall({ ...ctx, timestamp: Date.now() }); + } + + const result = service.checkConferenceAccess( + 'user-1', + 'room-1', + false, + ); + expect(result.allowed).toBe(false); + expect(result.requiresVerification).toBe(true); + expect(result.reason).toContain('restricted'); + }); + + it('flags when host has suspicious activity', () => { + const hostCtx = createContext({ userId: 'host-1' }); + + // Need 8 calls to accumulate 50+ score (calls 4-8 each add 10 pts) + for (let i = 0; i < 8; i++) { + vi.advanceTimersByTime(100); + service.checkStartCall({ ...hostCtx, timestamp: Date.now() }); + } + + const result = service.checkConferenceAccess( + 'user-2', + 'room-1', + false, + 'host-1', + ); + expect(result.allowed).toBe(false); + expect(result.reason).toContain('Host'); + }); + + it('applies monitoring in strict mode for moderate scores', () => { + service = new FraudDetectionService({ enableStrictMode: true }); + const ctx = createContext(); + + // Need 6 calls to accumulate 30+ score (calls 4-6 each add 10 pts) + for (let i = 0; i < 6; i++) { + vi.advanceTimersByTime(100); + service.checkStartCall({ ...ctx, timestamp: Date.now() }); + } + + const result = service.checkConferenceAccess( + 'user-1', + 'room-1', + false, + ); + expect(result.allowed).toBe(true); + expect(result.requiresVerification).toBe(true); + }); + }); + + describe('checkMeetingBombing', () => { + it('allows normal joins', () => { + const ctx = createContext(); + const result = service.checkMeetingBombing(ctx); + expect(result.blocked).toBe(false); + expect(result.isSuspicious).toBe(false); + }); + + it('detects rapid join bomb', () => { + const ctx = createContext(); + + // Simulate 3 rapid joins from different users + for (let i = 0; i < 3; i++) { + vi.advanceTimersByTime(100); + service.checkJoinMeeting({ + ...ctx, + userId: `user-${i}`, + timestamp: Date.now(), + }); + } + + const result = service.checkMeetingBombing({ + ...ctx, + userId: 'attacker', + timestamp: Date.now(), + }); + expect(result.blocked).toBe(true); + expect(result.isSuspicious).toBe(true); + expect(result.events.some((e) => e.category === 'MEETING_BOMBING')).toBe(true); + }); + }); + + describe('getEvents', () => { + it('returns all events when no userId specified', () => { + service.checkJoinMeeting(createContext()); + const events = service.getEvents(); + expect(events.length).toBeGreaterThanOrEqual(0); + }); + + it('filters events by userId', () => { + service.checkJoinMeeting(createContext({ userId: 'user-a' })); + service.checkJoinMeeting(createContext({ userId: 'user-b' })); + + const userAEvents = service.getEvents('user-a'); + expect(userAEvents.every((e) => e.userId === 'user-a')).toBe(true); + }); + }); + + describe('getUserScore', () => { + it('returns 0 for unknown users', () => { + expect(service.getUserScore('unknown')).toBe(0); + }); + + it('returns accumulated score', () => { + const ctx = createContext(); + + service.checkJoinMeeting(ctx); + expect(service.getUserScore('user-1')).toBe(0); // clean join + + // Trigger a rapid join event + for (let i = 0; i < 6; i++) { + vi.advanceTimersByTime(100); + service.checkJoinMeeting({ ...ctx, timestamp: Date.now() }); + } + expect(service.getUserScore('user-1')).toBeGreaterThan(0); + }); + }); + + describe('resetUserScore', () => { + it('resets score to 0', () => { + service.resetUserScore('user-1'); + expect(service.getUserScore('user-1')).toBe(0); + }); + }); + + describe('getConfig / updateConfig', () => { + it('returns current config', () => { + const config = service.getConfig(); + expect(config.maxCallsPerMinute).toBe(3); + }); + + it('updates config values', () => { + service.updateConfig({ maxCallsPerMinute: 10 }); + expect(service.getConfig().maxCallsPerMinute).toBe(10); + }); + }); + + describe('clearEvents', () => { + it('clears all events and scores', () => { + const ctx = createContext(); + + for (let i = 0; i < 6; i++) { + vi.advanceTimersByTime(100); + service.checkJoinMeeting({ ...ctx, timestamp: Date.now() }); + } + + expect(service.getEvents().length).toBeGreaterThan(0); + expect(service.getUserScore('user-1')).toBeGreaterThan(0); + + service.clearEvents(); + expect(service.getEvents()).toHaveLength(0); + expect(service.getUserScore('user-1')).toBe(0); + }); + }); +}); diff --git a/src/services/fraud-detection/index.ts b/src/services/fraud-detection/index.ts new file mode 100644 index 00000000..0d39977f --- /dev/null +++ b/src/services/fraud-detection/index.ts @@ -0,0 +1,396 @@ +import type { + FraudDetectionResult, + FraudEvent, + UserActionContext, + ConferenceAccessCheck, + FraudDetectionConfig, + FraudCategory, + FraudSeverity, +} from './types'; + +export type { + FraudDetectionResult, + FraudEvent, + UserActionContext, + ConferenceAccessCheck, + FraudDetectionConfig, + FraudCategory, + FraudSeverity, +}; + +const DEFAULT_CONFIG: FraudDetectionConfig = { + maxJoinLeavePerMinute: 5, + maxScreenShareTogglesPerMinute: 4, + maxCallsPerMinute: 3, + maxConnectionsPerUser: 2, + enableStrictMode: false, + blockOnCriticalThreats: true, +}; + +interface ActionRecord { + userId: string; + action: string; + roomId: string; + timestamp: number; +} + +interface ConnectionRecord { + userId: string; + roomId: string; + joinedAt: number; + active: boolean; +} + +export class FraudDetectionService { + private config: FraudDetectionConfig; + private actionLog: ActionRecord[] = []; + private connections: Map = new Map(); + private events: FraudEvent[] = []; + private userScores: Map = new Map(); + + constructor(config?: Partial) { + this.config = { ...DEFAULT_CONFIG, ...config }; + } + + getConfig(): FraudDetectionConfig { + return { ...this.config }; + } + + updateConfig(config: Partial): void { + this.config = { ...this.config, ...config }; + } + + private recordAction(action: string, context: UserActionContext): void { + this.actionLog.push({ + userId: context.userId, + action, + roomId: context.roomId, + timestamp: context.timestamp, + }); + + const cutoff = Date.now() - 60_000; + while (this.actionLog.length > 0 && this.actionLog[0].timestamp < cutoff) { + this.actionLog.shift(); + } + } + + private addEvent( + category: FraudCategory, + severity: FraudSeverity, + context: UserActionContext, + details: Record, + blocked: boolean, + ): FraudEvent { + const event: FraudEvent = { + id: `fraud-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, + timestamp: Date.now(), + category, + severity, + userId: context.userId, + roomId: context.roomId, + details, + blocked, + }; + this.events.push(event); + + const scoreIncrement = + severity === 'critical' + ? 40 + : severity === 'high' + ? 20 + : severity === 'medium' + ? 10 + : 5; + const currentScore = this.userScores.get(context.userId) ?? 0; + this.userScores.set(context.userId, currentScore + scoreIncrement); + + return event; + } + + getEvents(userId?: string): FraudEvent[] { + if (userId) { + return this.events.filter((e) => e.userId === userId); + } + return [...this.events]; + } + + getUserScore(userId: string): number { + return this.userScores.get(userId) ?? 0; + } + + resetUserScore(userId: string): void { + this.userScores.delete(userId); + } + + clearEvents(): void { + this.events = []; + this.userScores.clear(); + this.actionLog = []; + this.connections.clear(); + } + + checkJoinMeeting(context: UserActionContext): FraudDetectionResult { + const events: FraudEvent[] = []; + let score = 0; + let blocked = false; + + const recentJoins = this.actionLog.filter( + (a) => + a.userId === context.userId && + a.action === 'join' && + a.timestamp > Date.now() - 60_000, + ); + + if (recentJoins.length >= this.config.maxJoinLeavePerMinute) { + const event = this.addEvent( + 'RAPID_JOIN_LEAVE', + 'high', + context, + { recentJoins: recentJoins.length, action: 'join' }, + false, + ); + events.push(event); + score += 20; + } + + const activeUserConnections = this.connections.get(context.userId) ?? []; + const activeConnections = activeUserConnections.filter((c) => c.active); + if (activeConnections.length >= this.config.maxConnectionsPerUser) { + const event = this.addEvent( + 'MULTIPLE_CONNECTIONS', + 'high', + context, + { activeConnections: activeConnections.length }, + this.config.blockOnCriticalThreats, + ); + events.push(event); + score += 20; + if (this.config.blockOnCriticalThreats) { + blocked = true; + } + } + + const connection: ConnectionRecord = { + userId: context.userId, + roomId: context.roomId, + joinedAt: context.timestamp, + active: true, + }; + const existing = this.connections.get(context.userId) ?? []; + existing.push(connection); + this.connections.set(context.userId, existing); + + this.recordAction('join', context); + + return { + isSuspicious: events.length > 0, + blocked, + score, + events, + }; + } + + checkLeaveMeeting(context: UserActionContext): FraudDetectionResult { + const events: FraudEvent[] = []; + let score = 0; + + const connections = this.connections.get(context.userId) ?? []; + const activeConnection = connections.find( + (c) => c.roomId === context.roomId && c.active, + ); + if (activeConnection) { + activeConnection.active = false; + } + + const recentLeaves = this.actionLog.filter( + (a) => + a.userId === context.userId && + a.action === 'leave' && + a.timestamp > Date.now() - 60_000, + ); + + if (recentLeaves.length >= this.config.maxJoinLeavePerMinute) { + const event = this.addEvent( + 'RAPID_JOIN_LEAVE', + 'high', + context, + { recentLeaves: recentLeaves.length, action: 'leave' }, + false, + ); + events.push(event); + score += 20; + } + + this.recordAction('leave', context); + + return { + isSuspicious: events.length > 0, + blocked: false, + score, + events, + }; + } + + checkScreenShare( + context: UserActionContext, + enabled: boolean, + ): FraudDetectionResult { + const events: FraudEvent[] = []; + let score = 0; + + const recentToggles = this.actionLog.filter( + (a) => + a.userId === context.userId && + a.action === 'screen-share' && + a.timestamp > Date.now() - 60_000, + ); + + if (recentToggles.length >= this.config.maxScreenShareTogglesPerMinute) { + const event = this.addEvent( + 'SCREEN_SHARE_ABUSE', + 'medium', + context, + { recentToggles: recentToggles.length, enabled }, + false, + ); + events.push(event); + score += 10; + } + + this.recordAction('screen-share', context); + + return { + isSuspicious: events.length > 0, + blocked: false, + score, + events, + }; + } + + checkStartCall(context: UserActionContext): FraudDetectionResult { + const events: FraudEvent[] = []; + let score = 0; + let blocked = false; + + const recentCalls = this.actionLog.filter( + (a) => + a.userId === context.userId && + a.action === 'start-call' && + a.timestamp > Date.now() - 60_000, + ); + + if (recentCalls.length >= this.config.maxCallsPerMinute) { + const event = this.addEvent( + 'ACTION_ABUSE', + 'medium', + context, + { recentCalls: recentCalls.length, action: 'start-call' }, + false, + ); + events.push(event); + score += 10; + } + + const userScore = this.userScores.get(context.userId) ?? 0; + if (userScore >= 50) { + const event = this.addEvent( + 'SUSPICIOUS_IDENTITY', + 'critical', + context, + { totalScore: userScore }, + this.config.blockOnCriticalThreats, + ); + events.push(event); + score += 40; + if (this.config.blockOnCriticalThreats) { + blocked = true; + } + } + + this.recordAction('start-call', context); + + return { + isSuspicious: events.length > 0, + blocked, + score, + events, + }; + } + + checkConferenceAccess( + userId: string, + roomId: string, + isHost: boolean, + hostUserId?: string, + ): ConferenceAccessCheck { + if (isHost) { + return { allowed: true }; + } + + if (hostUserId && this.userScores.get(hostUserId) != null) { + const hostScore = this.userScores.get(hostUserId) ?? 0; + if (hostScore >= 50) { + return { + allowed: false, + reason: 'Host account flagged for suspicious activity', + requiresVerification: true, + }; + } + } + + const userScore = this.userScores.get(userId) ?? 0; + if (userScore >= 80) { + return { + allowed: false, + reason: + 'Account temporarily restricted due to suspicious activity', + requiresVerification: true, + }; + } + + if (this.config.enableStrictMode && userScore >= 30) { + return { + allowed: true, + reason: 'Access granted with monitoring', + requiresVerification: true, + }; + } + + return { allowed: true }; + } + + checkMeetingBombing(context: UserActionContext): FraudDetectionResult { + const events: FraudEvent[] = []; + let score = 0; + let blocked = false; + + const recentJoinsFromIp = this.actionLog.filter( + (a) => + a.action === 'join' && + a.roomId === context.roomId && + a.timestamp > Date.now() - 10_000, + ); + + if (recentJoinsFromIp.length >= 3) { + const event = this.addEvent( + 'MEETING_BOMBING', + 'critical', + context, + { rapidJoinsFromDifferentUsers: recentJoinsFromIp.length }, + true, + ); + events.push(event); + score += 40; + blocked = true; + } + + return { + isSuspicious: events.length > 0, + blocked, + score, + events, + }; + } +} + +export const fraudDetectionService = new FraudDetectionService(); diff --git a/src/services/fraud-detection/types.ts b/src/services/fraud-detection/types.ts new file mode 100644 index 00000000..792fcdd6 --- /dev/null +++ b/src/services/fraud-detection/types.ts @@ -0,0 +1,52 @@ +export type FraudSeverity = 'low' | 'medium' | 'high' | 'critical'; + +export type FraudCategory = + | 'RAPID_JOIN_LEAVE' + | 'MULTIPLE_CONNECTIONS' + | 'SCREEN_SHARE_ABUSE' + | 'UNAUTHORIZED_ACCESS' + | 'ACTION_ABUSE' + | 'SUSPICIOUS_IDENTITY' + | 'MEETING_BOMBING'; + +export interface FraudEvent { + id: string; + timestamp: number; + category: FraudCategory; + severity: FraudSeverity; + userId: string; + roomId: string; + details: Record; + blocked: boolean; +} + +export interface FraudDetectionResult { + isSuspicious: boolean; + blocked: boolean; + score: number; + events: FraudEvent[]; + message?: string; +} + +export interface UserActionContext { + userId: string; + userName: string; + roomId: string; + ipAddress?: string; + timestamp: number; +} + +export interface ConferenceAccessCheck { + allowed: boolean; + reason?: string; + requiresVerification?: boolean; +} + +export interface FraudDetectionConfig { + maxJoinLeavePerMinute: number; + maxScreenShareTogglesPerMinute: number; + maxCallsPerMinute: number; + maxConnectionsPerUser: number; + enableStrictMode: boolean; + blockOnCriticalThreats: boolean; +}