Skip to content

Commit 0a5dee3

Browse files
sirjamesgrayclaude
andcommitted
feat: Add comprehensive anti-spam and anti-bot protection system
Implement a multi-layered security system to protect WeWrite from spam, bots, and abuse while minimizing friction for legitimate users. Core Services: - RiskScoringService: Central risk assessment engine that combines multiple signals (bot detection, IP reputation, account trust, behavioral analysis, velocity tracking) to calculate risk scores 0-100 - AccountSecurityService: Manages account trust levels, security events, and trust score calculations based on account age, verification status, and activity patterns - ContentSpamDetectionService: Analyzes content for spam patterns using pattern matching and heuristics (link density, keyword stuffing, etc.) - IPReputationService: Tracks IP reputation with geo-location awareness and VPN/proxy detection signals - TurnstileVerificationService: Integrates Cloudflare Turnstile for CAPTCHA challenges (supports both visible and invisible modes) Risk Level Thresholds: - 0-30: ALLOW - No challenge required - 31-60: SOFT CHALLENGE - Invisible Turnstile challenge - 61-85: HARD CHALLENGE - Visible Turnstile challenge - 86-100: BLOCK - Action blocked, logged for review Rate Limiter Enhancements: - Add Redis store support (Upstash) for distributed rate limiting - Add tiered page creation limits based on account trust level - Add reply rate limiting - Add account creation rate limiting per IP Admin Dashboard: - Add RiskAssessmentSection component for user risk analysis - Add RiskScoreBadge component for visual risk indicators - Integrate risk assessment into user details drawer API: - Add /api/risk-assessment endpoint for risk evaluation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent d22aae2 commit 0a5dee3

15 files changed

+4702
-62
lines changed

app/admin/users/page.tsx

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import { UserDetailsDrawer } from "../../components/admin/UserDetailsDrawer";
4040
import { DndProvider, useDrag, useDrop } from 'react-dnd';
4141
import { HTML5Backend } from 'react-dnd-html5-backend';
4242
import SimpleSparkline from "../../components/utils/SimpleSparkline";
43+
import { RiskScoreBadge } from "../../components/admin/RiskScoreBadge";
4344

4445
type FinancialInfo = {
4546
hasSubscription: boolean;
@@ -72,8 +73,65 @@ type User = {
7273
referralSource?: string;
7374
pwaInstalled?: boolean;
7475
notificationSparkline?: number[];
76+
riskScore?: number; // Calculated risk score (0-100)
7577
};
7678

79+
/**
80+
* Calculate a simple risk score based on available user data.
81+
* This is a client-side approximation for display purposes.
82+
* Full risk assessment uses the server-side RiskScoringService.
83+
*/
84+
function calculateClientRiskScore(user: User): number {
85+
let score = 50; // Start at medium risk
86+
87+
// Account age reduces risk (max -30 points)
88+
if (user.createdAt) {
89+
const createdDate = user.createdAt?.toDate?.() || new Date(user.createdAt);
90+
const ageInDays = Math.floor((Date.now() - createdDate.getTime()) / (1000 * 60 * 60 * 24));
91+
if (ageInDays > 90) score -= 30;
92+
else if (ageInDays > 30) score -= 20;
93+
else if (ageInDays > 7) score -= 10;
94+
else score += 10; // New accounts are higher risk
95+
} else {
96+
score += 10; // Unknown creation date
97+
}
98+
99+
// Email verification reduces risk (-15 points)
100+
if (user.emailVerified) {
101+
score -= 15;
102+
} else {
103+
score += 5;
104+
}
105+
106+
// Content creation shows engagement (-15 points max)
107+
if (user.totalPages !== undefined) {
108+
if (user.totalPages > 50) score -= 15;
109+
else if (user.totalPages > 10) score -= 10;
110+
else if (user.totalPages > 0) score -= 5;
111+
else score += 5; // No content yet
112+
}
113+
114+
// Subscription shows commitment (-10 points)
115+
if (user.financial?.hasSubscription) {
116+
score -= 10;
117+
}
118+
119+
// Admin users are trusted (-20 points)
120+
if (user.isAdmin) {
121+
score -= 20;
122+
}
123+
124+
// Recent login shows activity (-5 points)
125+
if (user.lastLogin) {
126+
const lastDate = user.lastLogin?.toDate?.() || new Date(user.lastLogin);
127+
const daysSinceLogin = Math.floor((Date.now() - lastDate.getTime()) / (1000 * 60 * 60 * 24));
128+
if (daysSinceLogin < 7) score -= 5;
129+
}
130+
131+
// Clamp to 0-100
132+
return Math.max(0, Math.min(100, score));
133+
}
134+
77135
type Column = {
78136
id: string;
79137
label: string;
@@ -480,6 +538,19 @@ export default function AdminUsersPage({ drawerSubPath }: AdminUsersPageProps =
480538
)
481539
)
482540
},
541+
{
542+
id: "riskScore",
543+
label: "Risk",
544+
sortable: true,
545+
minWidth: 80,
546+
render: (u) => (
547+
<RiskScoreBadge
548+
score={u.riskScore ?? calculateClientRiskScore(u)}
549+
size="sm"
550+
showTooltip={true}
551+
/>
552+
)
553+
},
483554
{
484555
id: "referredBy",
485556
label: "Referred by",
@@ -697,6 +768,8 @@ export default function AdminUsersPage({ drawerSubPath }: AdminUsersPageProps =
697768
return u.emailVerified ? 1 : 0;
698769
case "admin":
699770
return u.isAdmin ? 1 : 0;
771+
case "riskScore":
772+
return u.riskScore ?? calculateClientRiskScore(u);
700773
case "referredBy":
701774
return u.referredBy ? 1 : 0;
702775
case "payouts":

app/api/risk-assessment/route.ts

Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
/**
2+
* Risk Assessment API
3+
*
4+
* Provides risk scoring for actions to determine if challenges are needed.
5+
*
6+
* Endpoints:
7+
* - POST /api/risk-assessment - Full risk assessment for an action
8+
* - GET /api/risk-assessment?userId=xxx - Get user's risk level (admin only)
9+
*
10+
* @see app/services/RiskScoringService.ts
11+
* @see docs/security/ANTI_SPAM_SYSTEM.md
12+
*/
13+
14+
import { NextRequest, NextResponse } from 'next/server';
15+
import {
16+
RiskScoringService,
17+
type ActionType,
18+
type RiskAssessmentInput
19+
} from '../../services/RiskScoringService';
20+
import { getUserIdFromRequest, createErrorResponse } from '../auth-helper';
21+
import { getFirebaseAdmin } from '../../firebase/admin';
22+
import { getCollectionName } from '../../utils/environmentConfig';
23+
24+
/**
25+
* POST /api/risk-assessment
26+
*
27+
* Perform a risk assessment for an action.
28+
* Used by client before sensitive actions to determine if challenge needed.
29+
*
30+
* Body:
31+
* {
32+
* action: ActionType,
33+
* sessionData?: { duration: number, interactions: number, pageViews: number },
34+
* contentLength?: number,
35+
* hasLinks?: boolean,
36+
* linkCount?: number,
37+
* fingerprint?: object
38+
* }
39+
*
40+
* Response:
41+
* {
42+
* success: true,
43+
* data: {
44+
* score: number,
45+
* level: 'allow' | 'soft_challenge' | 'hard_challenge' | 'block',
46+
* recommendation: string,
47+
* reasons: string[],
48+
* shouldChallenge: boolean,
49+
* challengeType?: 'invisible' | 'visible'
50+
* }
51+
* }
52+
*/
53+
export async function POST(request: NextRequest) {
54+
try {
55+
// Get client IP
56+
const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
57+
|| request.headers.get('x-real-ip')
58+
|| 'unknown';
59+
60+
// Get user agent
61+
const userAgent = request.headers.get('user-agent') || '';
62+
63+
// Get authenticated user (optional)
64+
const userId = await getUserIdFromRequest(request);
65+
66+
// Parse request body
67+
let body: Partial<RiskAssessmentInput>;
68+
try {
69+
body = await request.json();
70+
} catch {
71+
return createErrorResponse('BAD_REQUEST', 'Invalid JSON body');
72+
}
73+
74+
// Validate action type
75+
const validActions: ActionType[] = [
76+
'login', 'register', 'create_page', 'edit_page',
77+
'create_reply', 'send_message', 'password_reset',
78+
'email_change', 'account_delete'
79+
];
80+
81+
if (!body.action || !validActions.includes(body.action)) {
82+
return createErrorResponse('BAD_REQUEST', `Invalid action. Must be one of: ${validActions.join(', ')}`);
83+
}
84+
85+
// Build assessment input
86+
const input: RiskAssessmentInput = {
87+
action: body.action,
88+
userId: userId || undefined,
89+
ip,
90+
userAgent,
91+
fingerprint: body.fingerprint,
92+
sessionData: body.sessionData,
93+
contentLength: body.contentLength,
94+
hasLinks: body.hasLinks,
95+
linkCount: body.linkCount
96+
};
97+
98+
// Perform risk assessment
99+
const assessment = await RiskScoringService.assessRisk(input);
100+
101+
// Determine if challenge is needed
102+
const shouldChallenge = assessment.level === 'soft_challenge' || assessment.level === 'hard_challenge';
103+
const challengeType = assessment.level === 'soft_challenge' ? 'invisible' : assessment.level === 'hard_challenge' ? 'visible' : undefined;
104+
105+
return NextResponse.json({
106+
success: true,
107+
timestamp: new Date().toISOString(),
108+
data: {
109+
score: assessment.score,
110+
level: assessment.level,
111+
recommendation: assessment.recommendation,
112+
reasons: assessment.reasons,
113+
shouldChallenge,
114+
challengeType,
115+
// Don't expose full factors to client for security
116+
factors: {
117+
botDetection: { score: assessment.factors.botDetection.score },
118+
accountTrust: { trustLevel: assessment.factors.accountTrust.trustLevel },
119+
velocity: { exceededLimit: assessment.factors.velocity.exceededLimit }
120+
}
121+
}
122+
});
123+
} catch (error) {
124+
console.error('[risk-assessment] Error:', error);
125+
return createErrorResponse('INTERNAL_ERROR', 'Failed to assess risk');
126+
}
127+
}
128+
129+
/**
130+
* GET /api/risk-assessment?userId=xxx
131+
*
132+
* Get risk level for a specific user.
133+
* Admin only - used in admin dashboard.
134+
*
135+
* Query params:
136+
* - userId: The user to get risk level for
137+
*
138+
* Response:
139+
* {
140+
* success: true,
141+
* data: {
142+
* score: number,
143+
* level: string,
144+
* factors: RiskFactors,
145+
* lastAssessment?: Date,
146+
* history?: Array<{ timestamp, action, score, level, reasons }>
147+
* }
148+
* }
149+
*/
150+
export async function GET(request: NextRequest) {
151+
try {
152+
// Require authentication
153+
const adminUserId = await getUserIdFromRequest(request);
154+
if (!adminUserId) {
155+
return createErrorResponse('UNAUTHORIZED', 'Authentication required');
156+
}
157+
158+
// Check admin permissions
159+
const isAdmin = await checkIsAdmin(adminUserId);
160+
if (!isAdmin) {
161+
return createErrorResponse('FORBIDDEN', 'Admin access required');
162+
}
163+
164+
// Get userId from query params
165+
const { searchParams } = new URL(request.url);
166+
const userId = searchParams.get('userId');
167+
168+
if (!userId) {
169+
return createErrorResponse('BAD_REQUEST', 'userId query parameter required');
170+
}
171+
172+
// Get user risk level
173+
const riskLevel = await RiskScoringService.getUserRiskLevel(userId);
174+
175+
// Optionally get history
176+
const includeHistory = searchParams.get('history') === 'true';
177+
let history;
178+
if (includeHistory) {
179+
history = await RiskScoringService.getUserRiskHistory(userId, 10);
180+
}
181+
182+
return NextResponse.json({
183+
success: true,
184+
timestamp: new Date().toISOString(),
185+
data: {
186+
userId,
187+
score: riskLevel.score,
188+
level: riskLevel.level,
189+
factors: riskLevel.factors,
190+
lastAssessment: riskLevel.lastAssessment?.toISOString(),
191+
...(history && { history })
192+
}
193+
});
194+
} catch (error) {
195+
console.error('[risk-assessment] GET Error:', error);
196+
return createErrorResponse('INTERNAL_ERROR', 'Failed to get risk level');
197+
}
198+
}
199+
200+
/**
201+
* Check if a user has admin permissions
202+
*/
203+
async function checkIsAdmin(userId: string): Promise<boolean> {
204+
try {
205+
const admin = getFirebaseAdmin();
206+
if (!admin) {
207+
// In development, check for dev admin
208+
return userId === 'mP9yRa3nO6gS8wD4xE2hF5jK7m9N' || userId === 'dev_admin_user';
209+
}
210+
211+
const db = admin.firestore();
212+
const userDoc = await db.collection(getCollectionName('users')).doc(userId).get();
213+
const userData = userDoc.data();
214+
215+
return userData?.role === 'admin' || userData?.isAdmin === true;
216+
} catch (error) {
217+
console.error('[risk-assessment] Error checking admin:', error);
218+
return false;
219+
}
220+
}

0 commit comments

Comments
 (0)