@tuturuuu/utils 0.0.2 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +305 -0
- package/biome.json +5 -0
- package/jsr.json +8 -8
- package/package.json +63 -32
- package/src/__tests__/ai-temp-auth.test.ts +309 -0
- package/src/__tests__/api-proxy-guard.test.ts +1451 -0
- package/src/__tests__/app-url.test.ts +270 -0
- package/src/__tests__/avatar-url.test.ts +97 -0
- package/src/__tests__/color-helper.test.ts +179 -0
- package/src/__tests__/constants.test.ts +351 -0
- package/src/__tests__/crypto.test.ts +107 -0
- package/src/__tests__/date-helper.test.ts +408 -0
- package/src/__tests__/fixtures/task-description-full-featured.json +456 -0
- package/src/__tests__/format.test.ts +317 -0
- package/src/__tests__/html-sanitizer.test.ts +360 -0
- package/src/__tests__/interest-calculator.test.ts +336 -0
- package/src/__tests__/interest-detector.test.ts +222 -0
- package/src/__tests__/label-colors.test.ts +241 -0
- package/src/__tests__/name-helper.test.ts +158 -0
- package/src/__tests__/node-diff.test.ts +576 -0
- package/src/__tests__/notification-service.test.ts +210 -0
- package/src/__tests__/onboarding-helper.test.ts +331 -0
- package/src/__tests__/path-helper.test.ts +152 -0
- package/src/__tests__/permissions.test.tsx +81 -0
- package/src/__tests__/request-emoji-limit.test.ts +172 -0
- package/src/__tests__/search-helper.test.ts +51 -0
- package/src/__tests__/storage-display-name.test.ts +37 -0
- package/src/__tests__/storage-path.test.ts +238 -0
- package/src/__tests__/tag-utils.test.ts +205 -0
- package/src/__tests__/task-description-yjs-state.test.ts +581 -0
- package/src/__tests__/task-helper-board-api-routing.test.ts +94 -0
- package/src/__tests__/task-helper-create-task.test.ts +129 -0
- package/src/__tests__/task-helpers.test.ts +464 -0
- package/src/__tests__/task-overrides.test.ts +305 -0
- package/src/__tests__/task-reorder-cache.test.ts +74 -0
- package/src/__tests__/task-sort-keys.test.ts +36 -0
- package/src/__tests__/task-transformers.test.ts +62 -0
- package/src/__tests__/text-helper.test.ts +776 -0
- package/src/__tests__/time-helper.test.ts +70 -0
- package/src/__tests__/time-tracker-period.test.ts +55 -0
- package/src/__tests__/timezone.test.ts +117 -0
- package/src/__tests__/upstash-rest.test.ts +77 -0
- package/src/__tests__/uuid-helper.test.ts +133 -0
- package/src/__tests__/workspace-helper.test.ts +859 -0
- package/src/__tests__/workspace-limits.test.ts +255 -0
- package/src/__tests__/yjs-helper.test.ts +581 -0
- package/src/abuse-protection/__tests__/backend-rate-limit.test.ts +113 -0
- package/src/abuse-protection/__tests__/edge.test.ts +136 -0
- package/src/abuse-protection/__tests__/index.test.ts +562 -0
- package/src/abuse-protection/__tests__/reputation.test.ts +192 -0
- package/src/abuse-protection/backend-rate-limit.ts +44 -0
- package/src/abuse-protection/constants.ts +117 -0
- package/src/abuse-protection/edge.ts +223 -0
- package/src/abuse-protection/index.ts +1545 -0
- package/src/abuse-protection/reputation.ts +587 -0
- package/src/abuse-protection/types.ts +97 -0
- package/src/abuse-protection/user-agent.ts +124 -0
- package/src/abuse-protection/user-suspension.ts +231 -0
- package/src/ai-temp-auth.ts +315 -0
- package/src/api-proxy-guard.ts +965 -0
- package/src/app-url.ts +96 -0
- package/src/avatar-url.ts +64 -0
- package/src/break-duration.ts +84 -0
- package/src/calendar-auth-token.test.ts +37 -0
- package/src/calendar-auth-token.ts +19 -0
- package/src/calendar-sync-coordination.md +197 -0
- package/src/calendar-utils.test.ts +169 -0
- package/src/calendar-utils.ts +91 -0
- package/src/color-helper.ts +110 -0
- package/src/common/nextjs.tsx +99 -0
- package/src/common/scan.tsx +15 -0
- package/src/configs/reports.ts +160 -0
- package/src/constants.ts +85 -0
- package/src/crypto.ts +21 -0
- package/src/currencies.ts +97 -0
- package/src/date-helper.ts +313 -0
- package/src/editor/convert-to-task.ts +264 -0
- package/src/editor/index.ts +5 -0
- package/src/email/__tests__/client.test.ts +141 -0
- package/src/email/__tests__/validation.test.ts +46 -0
- package/src/email/client.ts +92 -0
- package/src/email/server.ts +128 -0
- package/src/email/validation.ts +11 -0
- package/src/encryption/__tests__/calendar-events.test.ts +411 -0
- package/src/encryption/__tests__/configuration.test.ts +114 -0
- package/src/encryption/__tests__/field-encryption.test.ts +232 -0
- package/src/encryption/__tests__/key-generation.test.ts +30 -0
- package/src/encryption/__tests__/performance-edge-cases.test.ts +187 -0
- package/src/encryption/__tests__/test-helpers.ts +22 -0
- package/src/encryption/__tests__/workspace-key-encryption.test.ts +129 -0
- package/src/encryption/encryption-service.ts +343 -0
- package/src/encryption/index.ts +25 -0
- package/src/encryption/types.ts +57 -0
- package/src/exchange-rates.ts +49 -0
- package/src/feature-flags/__tests__/feature-flags.test.ts +302 -0
- package/src/feature-flags/core.ts +322 -0
- package/src/feature-flags/data.ts +16 -0
- package/src/feature-flags/default.ts +18 -0
- package/src/feature-flags/index.ts +7 -0
- package/src/feature-flags/requestable-features.ts +79 -0
- package/src/feature-flags/types.ts +4 -0
- package/src/fetcher.ts +2 -0
- package/src/finance/index.ts +4 -0
- package/src/finance/interest-calculator.ts +456 -0
- package/src/finance/interest-detector.ts +141 -0
- package/src/finance/transform-invoice-results.ts +219 -0
- package/src/finance/wallet-permissions.test.ts +169 -0
- package/src/finance/wallet-permissions.ts +82 -0
- package/src/format.ts +122 -3
- package/src/generated/platform-build-metadata.ts +11 -0
- package/src/hooks/use-platform.ts +64 -0
- package/src/html-sanitizer.ts +155 -0
- package/src/internal-domains.ts +497 -0
- package/src/keyboard-preset.ts +109 -0
- package/src/label-colors.ts +213 -0
- package/src/launchable-apps.test.ts +126 -0
- package/src/launchable-apps.ts +490 -0
- package/src/name-helper.ts +269 -0
- package/src/next-config.test.ts +234 -0
- package/src/next-config.ts +203 -0
- package/src/node-diff.ts +375 -0
- package/src/notification-service.ts +379 -0
- package/src/nova/scores/__tests__/calculate.test.ts +254 -0
- package/src/nova/scores/calculate.ts +132 -0
- package/src/nova/submissions/check-permission.ts +132 -0
- package/src/onboarding-helper.ts +213 -0
- package/src/path-helper.ts +93 -0
- package/src/permissions.tsx +1170 -0
- package/src/plan-helpers.test.ts +188 -0
- package/src/plan-helpers.ts +80 -0
- package/src/platform-release.test.ts +74 -0
- package/src/platform-release.ts +155 -0
- package/src/portless.ts +124 -0
- package/src/priority-styles.ts +42 -0
- package/src/request-emoji-limit.ts +335 -0
- package/src/search-helper.ts +18 -0
- package/src/search.test.ts +89 -0
- package/src/search.ts +355 -0
- package/src/storage-display-name.ts +30 -0
- package/src/storage-path.ts +147 -0
- package/src/tag-utils.ts +159 -0
- package/src/task/reorder.ts +245 -0
- package/src/task/transformers.ts +149 -0
- package/src/task-date-timezone.ts +133 -0
- package/src/task-description-content.ts +240 -0
- package/src/task-helper/board.ts +193 -0
- package/src/task-helper/bulk-actions.ts +564 -0
- package/src/task-helper/personal-external-staging.ts +21 -0
- package/src/task-helper/recycle-bin.ts +202 -0
- package/src/task-helper/relationships.ts +346 -0
- package/src/task-helper/shared.ts +109 -0
- package/src/task-helper/sort-keys.ts +337 -0
- package/src/task-helper/task-hooks-basic.ts +342 -0
- package/src/task-helper/task-hooks-move.ts +264 -0
- package/src/task-helper/task-operations.ts +278 -0
- package/src/task-helper.ts +12 -0
- package/src/task-helpers.ts +241 -0
- package/src/task-list-status.ts +62 -0
- package/src/task-overrides.ts +82 -0
- package/src/task-snapshot.ts +374 -0
- package/src/text-diff.ts +81 -0
- package/src/text-helper.ts +537 -0
- package/src/time-helper.ts +63 -0
- package/src/time-tracker-period.ts +73 -0
- package/src/timeblock-helper.ts +418 -0
- package/src/timezone.ts +190 -0
- package/src/timezones.json +1271 -0
- package/src/upstash-rest.ts +56 -0
- package/src/user-helper.ts +296 -0
- package/src/uuid-helper.ts +11 -0
- package/src/workspace-handle.ts +10 -0
- package/src/workspace-helper.ts +1408 -0
- package/src/workspace-limits.ts +68 -0
- package/src/yjs-helper.ts +217 -0
- package/src/yjs-task-description.ts +81 -0
- package/tsconfig.json +3 -5
- package/tsconfig.typecheck.json +33 -0
- package/vitest.config.ts +36 -0
- package/dist/index.d.ts +0 -8
- package/dist/index.js +0 -2
- package/dist/index.js.map +0 -1
- package/dist/index.mjs +0 -2
- package/dist/index.mjs.map +0 -1
- package/eslint.config.mjs +0 -20
- package/rollup.config.js +0 -41
- package/src/index.ts +0 -1
|
@@ -0,0 +1,1545 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OTP Abuse Protection System
|
|
3
|
+
*
|
|
4
|
+
* Provides rate limiting and IP blocking for OTP-related operations
|
|
5
|
+
* to prevent brute force attacks and enumeration.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { createHash } from 'node:crypto';
|
|
9
|
+
import type { SupabaseClient } from '@tuturuuu/supabase';
|
|
10
|
+
import type { Database, Json } from '@tuturuuu/types';
|
|
11
|
+
import { getUpstashRestRedisClient, hasUpstashRestEnv } from '../upstash-rest';
|
|
12
|
+
import {
|
|
13
|
+
ABUSE_THRESHOLDS,
|
|
14
|
+
BLOCK_DURATIONS,
|
|
15
|
+
MAX_BLOCK_LEVEL,
|
|
16
|
+
REDIS_KEYS,
|
|
17
|
+
WINDOW_MS,
|
|
18
|
+
} from './constants';
|
|
19
|
+
import type {
|
|
20
|
+
AbuseCheckResult,
|
|
21
|
+
AbuseEventType,
|
|
22
|
+
AbuseProtectionLogContext,
|
|
23
|
+
BlockInfo,
|
|
24
|
+
LogAbuseEventOptions,
|
|
25
|
+
RedisClient,
|
|
26
|
+
} from './types';
|
|
27
|
+
|
|
28
|
+
// Re-export types and constants
|
|
29
|
+
export * from './constants';
|
|
30
|
+
export * from './reputation';
|
|
31
|
+
export * from './types';
|
|
32
|
+
export * from './user-agent';
|
|
33
|
+
|
|
34
|
+
// In-memory fallback store
|
|
35
|
+
const memoryStore = new Map<string, { count: number; expiresAt: number }>();
|
|
36
|
+
|
|
37
|
+
// Redis client singleton (lazy initialized)
|
|
38
|
+
let redisClient: RedisClient | null = null;
|
|
39
|
+
let redisInitialized = false;
|
|
40
|
+
|
|
41
|
+
function parsePositiveIntEnv(name: string, fallback: number): number {
|
|
42
|
+
const rawValue = process.env[name];
|
|
43
|
+
if (!rawValue) {
|
|
44
|
+
return fallback;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const parsed = Number.parseInt(rawValue, 10);
|
|
48
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function getOTPSendIpLimits() {
|
|
52
|
+
return {
|
|
53
|
+
perDay: parsePositiveIntEnv(
|
|
54
|
+
'ABUSE_OTP_SEND_IP_LIMIT_DAY',
|
|
55
|
+
ABUSE_THRESHOLDS.OTP_SEND_PER_DAY
|
|
56
|
+
),
|
|
57
|
+
perHour: parsePositiveIntEnv(
|
|
58
|
+
'ABUSE_OTP_SEND_IP_LIMIT_HOUR',
|
|
59
|
+
ABUSE_THRESHOLDS.OTP_SEND_PER_HOUR
|
|
60
|
+
),
|
|
61
|
+
perMinute: parsePositiveIntEnv(
|
|
62
|
+
'ABUSE_OTP_SEND_IP_LIMIT_MINUTE',
|
|
63
|
+
ABUSE_THRESHOLDS.OTP_SEND_PER_MINUTE
|
|
64
|
+
),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Initialize Redis client from Upstash environment variables
|
|
70
|
+
*/
|
|
71
|
+
async function getRedisClient(): Promise<RedisClient | null> {
|
|
72
|
+
if (redisInitialized) return redisClient;
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
if (!hasUpstashRestEnv()) {
|
|
76
|
+
console.warn(
|
|
77
|
+
'[Abuse Protection] Redis not configured - falling back to memory'
|
|
78
|
+
);
|
|
79
|
+
redisInitialized = true;
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
redisClient = await getUpstashRestRedisClient();
|
|
84
|
+
redisInitialized = true;
|
|
85
|
+
return redisClient;
|
|
86
|
+
} catch (error) {
|
|
87
|
+
console.warn('[Abuse Protection] Redis unavailable:', error);
|
|
88
|
+
redisInitialized = true;
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Validate IP address format
|
|
95
|
+
*/
|
|
96
|
+
function isValidIP(ip: string): boolean {
|
|
97
|
+
const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/;
|
|
98
|
+
const ipv6Regex = /^([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}$/;
|
|
99
|
+
return ipv4Regex.test(ip) || ipv6Regex.test(ip);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Extract client IP address from request headers
|
|
104
|
+
* Works with Next.js headers() in Server Actions
|
|
105
|
+
*/
|
|
106
|
+
export function extractIPFromHeaders(
|
|
107
|
+
headers: Headers | Map<string, string> | Record<string, string | null>
|
|
108
|
+
): string {
|
|
109
|
+
const getHeader = (name: string): string | null => {
|
|
110
|
+
if (headers instanceof Headers) {
|
|
111
|
+
return headers.get(name);
|
|
112
|
+
}
|
|
113
|
+
if (headers instanceof Map) {
|
|
114
|
+
return headers.get(name) || null;
|
|
115
|
+
}
|
|
116
|
+
return headers[name] || null;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
// Check cf-connecting-ip (Cloudflare)
|
|
120
|
+
const cfIP = getHeader('cf-connecting-ip');
|
|
121
|
+
if (cfIP && isValidIP(cfIP)) {
|
|
122
|
+
return cfIP;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Check true-client-ip (some Cloudflare/enterprise proxy setups)
|
|
126
|
+
const trueClientIP = getHeader('true-client-ip');
|
|
127
|
+
if (trueClientIP && isValidIP(trueClientIP)) {
|
|
128
|
+
return trueClientIP;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Check x-forwarded-for after explicit client IP headers
|
|
132
|
+
const forwardedFor = getHeader('x-forwarded-for');
|
|
133
|
+
if (forwardedFor) {
|
|
134
|
+
const firstIP = forwardedFor.split(',')[0]?.trim();
|
|
135
|
+
if (firstIP && isValidIP(firstIP)) {
|
|
136
|
+
return firstIP;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Check x-real-ip (Nginx)
|
|
141
|
+
const realIP = getHeader('x-real-ip');
|
|
142
|
+
if (realIP && isValidIP(realIP)) {
|
|
143
|
+
return realIP;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return 'unknown';
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Hash email for privacy when storing in logs
|
|
151
|
+
*/
|
|
152
|
+
export function hashEmail(email: string): string {
|
|
153
|
+
return createHash('sha256')
|
|
154
|
+
.update(email.trim().toLowerCase())
|
|
155
|
+
.digest('hex')
|
|
156
|
+
.substring(0, 16);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function normalizeAbuseEventEmail(email?: string): string | null {
|
|
160
|
+
const normalized = email?.trim().toLowerCase();
|
|
161
|
+
return normalized ? normalized : null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function sanitizeAbuseLogValue(value: string | null | undefined) {
|
|
165
|
+
const normalized = value?.trim();
|
|
166
|
+
return normalized ? normalized.slice(0, 256) : null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function createAbuseLogContext(
|
|
170
|
+
ipAddress: string,
|
|
171
|
+
context?: AbuseProtectionLogContext
|
|
172
|
+
) {
|
|
173
|
+
return {
|
|
174
|
+
ipAddress,
|
|
175
|
+
operation: 'is_ip_blocked',
|
|
176
|
+
route: sanitizeAbuseLogValue(context?.route),
|
|
177
|
+
source: sanitizeAbuseLogValue(context?.source),
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Increment a counter in Redis or memory with automatic expiration
|
|
183
|
+
*/
|
|
184
|
+
async function incrementCounter(
|
|
185
|
+
key: string,
|
|
186
|
+
windowMs: number
|
|
187
|
+
): Promise<{ count: number; ttl: number }> {
|
|
188
|
+
const redis = await getRedisClient();
|
|
189
|
+
|
|
190
|
+
if (redis) {
|
|
191
|
+
try {
|
|
192
|
+
const count = await redis.incr(key);
|
|
193
|
+
if (count === 1) {
|
|
194
|
+
await redis.expire(key, Math.ceil(windowMs / 1000));
|
|
195
|
+
}
|
|
196
|
+
const ttl = await redis.ttl(key);
|
|
197
|
+
return { count, ttl: ttl > 0 ? ttl : Math.ceil(windowMs / 1000) };
|
|
198
|
+
} catch (error) {
|
|
199
|
+
console.error('[Abuse Protection] Redis error:', error);
|
|
200
|
+
// Fall through to memory
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Memory fallback
|
|
205
|
+
const now = Date.now();
|
|
206
|
+
const existing = memoryStore.get(key);
|
|
207
|
+
|
|
208
|
+
if (!existing || now > existing.expiresAt) {
|
|
209
|
+
memoryStore.set(key, { count: 1, expiresAt: now + windowMs });
|
|
210
|
+
return { count: 1, ttl: Math.ceil(windowMs / 1000) };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
existing.count++;
|
|
214
|
+
return {
|
|
215
|
+
count: existing.count,
|
|
216
|
+
ttl: Math.ceil((existing.expiresAt - now) / 1000),
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Get counter value from Redis or memory
|
|
222
|
+
*/
|
|
223
|
+
async function getCounter(key: string): Promise<number> {
|
|
224
|
+
const redis = await getRedisClient();
|
|
225
|
+
|
|
226
|
+
if (redis) {
|
|
227
|
+
try {
|
|
228
|
+
const count = await redis.get<number>(key);
|
|
229
|
+
return count || 0;
|
|
230
|
+
} catch {
|
|
231
|
+
// Fall through to memory
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const existing = memoryStore.get(key);
|
|
236
|
+
if (!existing || Date.now() > existing.expiresAt) {
|
|
237
|
+
return 0;
|
|
238
|
+
}
|
|
239
|
+
return existing.count;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function getCounterWithTTL(
|
|
243
|
+
key: string
|
|
244
|
+
): Promise<{ count: number; ttl: number }> {
|
|
245
|
+
const redis = await getRedisClient();
|
|
246
|
+
|
|
247
|
+
if (redis) {
|
|
248
|
+
try {
|
|
249
|
+
const [count, ttl] = await Promise.all([
|
|
250
|
+
redis.get<number>(key),
|
|
251
|
+
redis.ttl(key),
|
|
252
|
+
]);
|
|
253
|
+
|
|
254
|
+
return {
|
|
255
|
+
count: count || 0,
|
|
256
|
+
ttl: ttl > 0 ? ttl : 0,
|
|
257
|
+
};
|
|
258
|
+
} catch {
|
|
259
|
+
// Fall through to memory
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const existing = memoryStore.get(key);
|
|
264
|
+
if (!existing) {
|
|
265
|
+
return { count: 0, ttl: 0 };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const now = Date.now();
|
|
269
|
+
if (now > existing.expiresAt) {
|
|
270
|
+
memoryStore.delete(key);
|
|
271
|
+
return { count: 0, ttl: 0 };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return {
|
|
275
|
+
count: existing.count,
|
|
276
|
+
ttl: Math.ceil((existing.expiresAt - now) / 1000),
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Delete keys from Redis or memory
|
|
282
|
+
*/
|
|
283
|
+
async function deleteKeys(...keys: string[]): Promise<void> {
|
|
284
|
+
const redis = await getRedisClient();
|
|
285
|
+
|
|
286
|
+
if (redis) {
|
|
287
|
+
try {
|
|
288
|
+
await redis.del(...keys);
|
|
289
|
+
return;
|
|
290
|
+
} catch {
|
|
291
|
+
// Fall through to memory
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
for (const key of keys) {
|
|
296
|
+
memoryStore.delete(key);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async function deleteKeysWithCount(...keys: string[]): Promise<number> {
|
|
301
|
+
if (keys.length === 0) {
|
|
302
|
+
return 0;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const redis = await getRedisClient();
|
|
306
|
+
|
|
307
|
+
if (redis) {
|
|
308
|
+
try {
|
|
309
|
+
const deleted = await redis.del(...keys);
|
|
310
|
+
return typeof deleted === 'number' ? deleted : 0;
|
|
311
|
+
} catch {
|
|
312
|
+
// Fall through to memory
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
let deleted = 0;
|
|
317
|
+
for (const key of keys) {
|
|
318
|
+
if (memoryStore.delete(key)) {
|
|
319
|
+
deleted++;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return deleted;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Create Supabase admin client for database operations
|
|
328
|
+
*/
|
|
329
|
+
async function getSupabaseAdmin(): Promise<SupabaseClient<Database> | null> {
|
|
330
|
+
try {
|
|
331
|
+
const { createAdminClient } = await import(
|
|
332
|
+
'@tuturuuu/supabase/next/server'
|
|
333
|
+
);
|
|
334
|
+
return (await createAdminClient()) as SupabaseClient<Database>;
|
|
335
|
+
} catch (error) {
|
|
336
|
+
console.error(
|
|
337
|
+
'[Abuse Protection] Failed to create Supabase client:',
|
|
338
|
+
error
|
|
339
|
+
);
|
|
340
|
+
return null;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Check if an IP is currently blocked
|
|
346
|
+
*/
|
|
347
|
+
export async function isIPBlocked(
|
|
348
|
+
ipAddress: string,
|
|
349
|
+
context?: AbuseProtectionLogContext
|
|
350
|
+
): Promise<BlockInfo | null> {
|
|
351
|
+
const redis = await getRedisClient();
|
|
352
|
+
|
|
353
|
+
// Check Redis cache first
|
|
354
|
+
if (redis) {
|
|
355
|
+
try {
|
|
356
|
+
const cached = await redis.get<string>(REDIS_KEYS.IP_BLOCKED(ipAddress));
|
|
357
|
+
if (cached) {
|
|
358
|
+
const blockInfo =
|
|
359
|
+
typeof cached === 'string' ? JSON.parse(cached) : cached;
|
|
360
|
+
if (new Date(blockInfo.expiresAt) > new Date()) {
|
|
361
|
+
return {
|
|
362
|
+
id: blockInfo.id,
|
|
363
|
+
blockLevel: blockInfo.level,
|
|
364
|
+
reason: blockInfo.reason,
|
|
365
|
+
expiresAt: new Date(blockInfo.expiresAt),
|
|
366
|
+
blockedAt: new Date(blockInfo.blockedAt),
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
} catch (error) {
|
|
371
|
+
console.error(
|
|
372
|
+
'[Abuse Protection] Redis cache error:',
|
|
373
|
+
error,
|
|
374
|
+
createAbuseLogContext(ipAddress, context)
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Check database
|
|
380
|
+
try {
|
|
381
|
+
const sbAdmin = await getSupabaseAdmin();
|
|
382
|
+
if (!sbAdmin) return null;
|
|
383
|
+
|
|
384
|
+
const { data, error } = await sbAdmin.rpc('get_active_ip_block', {
|
|
385
|
+
p_ip_address: ipAddress,
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
if (error || !data || (Array.isArray(data) && data.length === 0)) {
|
|
389
|
+
return null;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const block = Array.isArray(data) ? data[0] : data;
|
|
393
|
+
if (!block) {
|
|
394
|
+
return null;
|
|
395
|
+
}
|
|
396
|
+
const blockInfo: BlockInfo = {
|
|
397
|
+
id: block.id,
|
|
398
|
+
blockLevel: block.block_level,
|
|
399
|
+
reason: block.reason,
|
|
400
|
+
expiresAt: new Date(block.expires_at),
|
|
401
|
+
blockedAt: new Date(block.blocked_at),
|
|
402
|
+
};
|
|
403
|
+
|
|
404
|
+
// Cache in Redis
|
|
405
|
+
if (redis && blockInfo.expiresAt > new Date()) {
|
|
406
|
+
const ttl = Math.ceil(
|
|
407
|
+
(blockInfo.expiresAt.getTime() - Date.now()) / 1000
|
|
408
|
+
);
|
|
409
|
+
await redis.set(
|
|
410
|
+
REDIS_KEYS.IP_BLOCKED(ipAddress),
|
|
411
|
+
JSON.stringify({
|
|
412
|
+
id: blockInfo.id,
|
|
413
|
+
level: blockInfo.blockLevel,
|
|
414
|
+
reason: blockInfo.reason,
|
|
415
|
+
expiresAt: blockInfo.expiresAt.toISOString(),
|
|
416
|
+
blockedAt: blockInfo.blockedAt.toISOString(),
|
|
417
|
+
}),
|
|
418
|
+
{ ex: ttl }
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
return blockInfo;
|
|
423
|
+
} catch (error) {
|
|
424
|
+
console.error('[Abuse Protection] DB error checking block:', error);
|
|
425
|
+
return null;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Block an IP address with progressive duration
|
|
431
|
+
*/
|
|
432
|
+
export async function blockIP(
|
|
433
|
+
ipAddress: string,
|
|
434
|
+
reason: AbuseEventType,
|
|
435
|
+
metadata?: Record<string, unknown>
|
|
436
|
+
): Promise<void> {
|
|
437
|
+
try {
|
|
438
|
+
const sbAdmin = await getSupabaseAdmin();
|
|
439
|
+
if (!sbAdmin) return;
|
|
440
|
+
|
|
441
|
+
const redis = await getRedisClient();
|
|
442
|
+
|
|
443
|
+
// Get current block level
|
|
444
|
+
let currentLevel = 0;
|
|
445
|
+
if (redis) {
|
|
446
|
+
try {
|
|
447
|
+
const level = await redis.get<number>(
|
|
448
|
+
REDIS_KEYS.IP_BLOCK_LEVEL(ipAddress)
|
|
449
|
+
);
|
|
450
|
+
currentLevel = level || 0;
|
|
451
|
+
} catch {
|
|
452
|
+
// Check DB fallback
|
|
453
|
+
const { data } = await sbAdmin.rpc('get_ip_block_level', {
|
|
454
|
+
p_ip_address: ipAddress,
|
|
455
|
+
});
|
|
456
|
+
currentLevel = (data as number) || 0;
|
|
457
|
+
}
|
|
458
|
+
} else {
|
|
459
|
+
const { data } = await sbAdmin.rpc('get_ip_block_level', {
|
|
460
|
+
p_ip_address: ipAddress,
|
|
461
|
+
});
|
|
462
|
+
currentLevel = (data as number) || 0;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// Calculate new block level (max 4)
|
|
466
|
+
const newLevel = Math.min(currentLevel + 1, MAX_BLOCK_LEVEL) as
|
|
467
|
+
| 1
|
|
468
|
+
| 2
|
|
469
|
+
| 3
|
|
470
|
+
| 4;
|
|
471
|
+
const blockDuration = BLOCK_DURATIONS[newLevel];
|
|
472
|
+
const expiresAt = new Date(Date.now() + blockDuration * 1000);
|
|
473
|
+
|
|
474
|
+
// Insert block record
|
|
475
|
+
const { data: blockRecord, error } = await sbAdmin
|
|
476
|
+
.from('blocked_ips')
|
|
477
|
+
.insert([
|
|
478
|
+
{
|
|
479
|
+
ip_address: ipAddress,
|
|
480
|
+
reason: reason as never,
|
|
481
|
+
block_level: newLevel,
|
|
482
|
+
expires_at: expiresAt.toISOString(),
|
|
483
|
+
metadata: (metadata || {}) as Json,
|
|
484
|
+
},
|
|
485
|
+
])
|
|
486
|
+
.select('id')
|
|
487
|
+
.single();
|
|
488
|
+
|
|
489
|
+
if (error) {
|
|
490
|
+
console.error('[Abuse Protection] Error creating block:', error);
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// Update Redis cache
|
|
495
|
+
if (redis) {
|
|
496
|
+
await Promise.all([
|
|
497
|
+
redis.set(
|
|
498
|
+
REDIS_KEYS.IP_BLOCKED(ipAddress),
|
|
499
|
+
JSON.stringify({
|
|
500
|
+
id: blockRecord.id,
|
|
501
|
+
level: newLevel,
|
|
502
|
+
reason,
|
|
503
|
+
expiresAt: expiresAt.toISOString(),
|
|
504
|
+
blockedAt: new Date().toISOString(),
|
|
505
|
+
}),
|
|
506
|
+
{ ex: blockDuration }
|
|
507
|
+
),
|
|
508
|
+
redis.set(REDIS_KEYS.IP_BLOCK_LEVEL(ipAddress), newLevel, {
|
|
509
|
+
ex: WINDOW_MS.TWENTY_FOUR_HOURS / 1000,
|
|
510
|
+
}),
|
|
511
|
+
]);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
console.log(
|
|
515
|
+
`[Abuse Protection] Blocked IP ${ipAddress} at level ${newLevel} for ${blockDuration}s due to ${reason}`
|
|
516
|
+
);
|
|
517
|
+
} catch (error) {
|
|
518
|
+
console.error('[Abuse Protection] Error blocking IP:', error);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Manually unblock an IP address
|
|
524
|
+
*/
|
|
525
|
+
export async function unblockIP(
|
|
526
|
+
ipAddress: string,
|
|
527
|
+
unblockingUserId: string,
|
|
528
|
+
reason?: string
|
|
529
|
+
): Promise<boolean> {
|
|
530
|
+
try {
|
|
531
|
+
const sbAdmin = await getSupabaseAdmin();
|
|
532
|
+
if (!sbAdmin) return false;
|
|
533
|
+
|
|
534
|
+
const redis = await getRedisClient();
|
|
535
|
+
|
|
536
|
+
// Update all active blocks for this IP
|
|
537
|
+
const { error } = await sbAdmin
|
|
538
|
+
.from('blocked_ips')
|
|
539
|
+
.update({
|
|
540
|
+
status: 'manually_unblocked',
|
|
541
|
+
unblocked_at: new Date().toISOString(),
|
|
542
|
+
unblocked_by: unblockingUserId,
|
|
543
|
+
unblock_reason: reason || 'Manual unblock by admin',
|
|
544
|
+
})
|
|
545
|
+
.eq('ip_address', ipAddress)
|
|
546
|
+
.eq('status', 'active');
|
|
547
|
+
|
|
548
|
+
if (error) {
|
|
549
|
+
console.error('[Abuse Protection] Error unblocking IP:', error);
|
|
550
|
+
return false;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// Clear Redis cache
|
|
554
|
+
if (redis) {
|
|
555
|
+
await deleteKeys(
|
|
556
|
+
REDIS_KEYS.IP_BLOCKED(ipAddress),
|
|
557
|
+
REDIS_KEYS.IP_BLOCK_LEVEL(ipAddress)
|
|
558
|
+
);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
console.log(
|
|
562
|
+
`[Abuse Protection] Unblocked IP ${ipAddress} by user ${unblockingUserId}`
|
|
563
|
+
);
|
|
564
|
+
return true;
|
|
565
|
+
} catch (error) {
|
|
566
|
+
console.error('[Abuse Protection] Error unblocking IP:', error);
|
|
567
|
+
return false;
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* Log an abuse event for audit trail
|
|
573
|
+
*/
|
|
574
|
+
export async function logAbuseEvent(
|
|
575
|
+
ipAddress: string,
|
|
576
|
+
eventType: AbuseEventType,
|
|
577
|
+
options?: LogAbuseEventOptions
|
|
578
|
+
): Promise<void> {
|
|
579
|
+
try {
|
|
580
|
+
const sbAdmin = await getSupabaseAdmin();
|
|
581
|
+
if (!sbAdmin) return;
|
|
582
|
+
const normalizedEmail = normalizeAbuseEventEmail(options?.email);
|
|
583
|
+
|
|
584
|
+
await sbAdmin.from('abuse_events').insert([
|
|
585
|
+
{
|
|
586
|
+
ip_address: ipAddress,
|
|
587
|
+
event_type: eventType as never,
|
|
588
|
+
email: normalizedEmail,
|
|
589
|
+
email_hash: normalizedEmail ? hashEmail(normalizedEmail) : null,
|
|
590
|
+
user_agent: options?.userAgent?.substring(0, 500),
|
|
591
|
+
endpoint: options?.endpoint,
|
|
592
|
+
success: options?.success ?? false,
|
|
593
|
+
metadata: (options?.metadata || {}) as Json,
|
|
594
|
+
},
|
|
595
|
+
]);
|
|
596
|
+
} catch (error) {
|
|
597
|
+
console.error('[Abuse Protection] Error logging event:', error);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
export interface ResetOtpLimitsForEmailOptions {
|
|
602
|
+
email: string;
|
|
603
|
+
clearEmailScoped: boolean;
|
|
604
|
+
clearRelatedIpCounters: boolean;
|
|
605
|
+
clearRelatedIpBlocks: boolean;
|
|
606
|
+
adminUserId: string;
|
|
607
|
+
reason?: string;
|
|
608
|
+
adminIpAddress?: string;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
export interface ResetOtpLimitsForEmailResult {
|
|
612
|
+
relatedIps: string[];
|
|
613
|
+
clearedEmailKeys: number;
|
|
614
|
+
clearedIpCounterCount: number;
|
|
615
|
+
unblockedIpCount: number;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
function isAsciiLetterOrDigit(char: string): boolean {
|
|
619
|
+
const code = char.charCodeAt(0);
|
|
620
|
+
|
|
621
|
+
return (
|
|
622
|
+
(code >= 48 && code <= 57) ||
|
|
623
|
+
(code >= 65 && code <= 90) ||
|
|
624
|
+
(code >= 97 && code <= 122)
|
|
625
|
+
);
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function isAllowedLocalEmailCharacter(char: string): boolean {
|
|
629
|
+
if (isAsciiLetterOrDigit(char)) {
|
|
630
|
+
return true;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
switch (char) {
|
|
634
|
+
case '!':
|
|
635
|
+
case '#':
|
|
636
|
+
case '$':
|
|
637
|
+
case '%':
|
|
638
|
+
case '&':
|
|
639
|
+
case "'":
|
|
640
|
+
case '*':
|
|
641
|
+
case '+':
|
|
642
|
+
case '-':
|
|
643
|
+
case '.':
|
|
644
|
+
case '/':
|
|
645
|
+
case '=':
|
|
646
|
+
case '?':
|
|
647
|
+
case '^':
|
|
648
|
+
case '_':
|
|
649
|
+
case '`':
|
|
650
|
+
case '{':
|
|
651
|
+
case '|':
|
|
652
|
+
case '}':
|
|
653
|
+
case '~':
|
|
654
|
+
return true;
|
|
655
|
+
default:
|
|
656
|
+
return false;
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
function isValidEmailDomainLabel(label: string): boolean {
|
|
661
|
+
if (!label || label.startsWith('-') || label.endsWith('-')) {
|
|
662
|
+
return false;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
for (const char of label) {
|
|
666
|
+
if (!isAsciiLetterOrDigit(char) && char !== '-') {
|
|
667
|
+
return false;
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
return true;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
function hasWhitespace(text: string): boolean {
|
|
675
|
+
for (const char of text) {
|
|
676
|
+
if (
|
|
677
|
+
char === ' ' ||
|
|
678
|
+
char === '\t' ||
|
|
679
|
+
char === '\n' ||
|
|
680
|
+
char === '\r' ||
|
|
681
|
+
char === '\f' ||
|
|
682
|
+
char === '\v'
|
|
683
|
+
) {
|
|
684
|
+
return true;
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
return false;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function isValidNormalizedEmail(email: string): boolean {
|
|
692
|
+
if (!email || email.length > 254 || hasWhitespace(email)) {
|
|
693
|
+
return false;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
const atIndex = email.indexOf('@');
|
|
697
|
+
if (atIndex <= 0 || atIndex !== email.lastIndexOf('@')) {
|
|
698
|
+
return false;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
const localPart = email.slice(0, atIndex);
|
|
702
|
+
const domainPart = email.slice(atIndex + 1);
|
|
703
|
+
|
|
704
|
+
if (
|
|
705
|
+
!localPart ||
|
|
706
|
+
!domainPart ||
|
|
707
|
+
localPart.length > 64 ||
|
|
708
|
+
domainPart.length > 253 ||
|
|
709
|
+
localPart.startsWith('.') ||
|
|
710
|
+
localPart.endsWith('.') ||
|
|
711
|
+
localPart.includes('..') ||
|
|
712
|
+
domainPart.startsWith('.') ||
|
|
713
|
+
domainPart.endsWith('.') ||
|
|
714
|
+
domainPart.includes('..')
|
|
715
|
+
) {
|
|
716
|
+
return false;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
for (const char of localPart) {
|
|
720
|
+
if (!isAllowedLocalEmailCharacter(char)) {
|
|
721
|
+
return false;
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
const domainLabels = domainPart.split('.');
|
|
726
|
+
if (domainLabels.length < 2) {
|
|
727
|
+
return false;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
for (const label of domainLabels) {
|
|
731
|
+
if (!isValidEmailDomainLabel(label)) {
|
|
732
|
+
return false;
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
const topLevelDomain = domainLabels[domainLabels.length - 1];
|
|
737
|
+
if (!topLevelDomain || topLevelDomain.length < 2) {
|
|
738
|
+
return false;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
return true;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
function normalizeOtpResetEmail(email: string): string {
|
|
745
|
+
const normalized = email.trim().toLowerCase();
|
|
746
|
+
|
|
747
|
+
if (!isValidNormalizedEmail(normalized)) {
|
|
748
|
+
throw new Error('Email is invalid');
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
return normalized;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
function getOtpResetScopeKeys(ipAddress: string): string[] {
|
|
755
|
+
return [
|
|
756
|
+
REDIS_KEYS.OTP_SEND(ipAddress),
|
|
757
|
+
REDIS_KEYS.OTP_SEND_HOURLY(ipAddress),
|
|
758
|
+
REDIS_KEYS.OTP_SEND_DAILY(ipAddress),
|
|
759
|
+
REDIS_KEYS.OTP_VERIFY_FAILED(ipAddress),
|
|
760
|
+
REDIS_KEYS.IP_BLOCK_LEVEL(ipAddress),
|
|
761
|
+
];
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
export async function resetOtpLimitsForEmail({
|
|
765
|
+
email,
|
|
766
|
+
clearEmailScoped,
|
|
767
|
+
clearRelatedIpCounters,
|
|
768
|
+
clearRelatedIpBlocks,
|
|
769
|
+
adminUserId,
|
|
770
|
+
reason,
|
|
771
|
+
adminIpAddress,
|
|
772
|
+
}: ResetOtpLimitsForEmailOptions): Promise<ResetOtpLimitsForEmailResult> {
|
|
773
|
+
if (!clearEmailScoped && !clearRelatedIpCounters && !clearRelatedIpBlocks) {
|
|
774
|
+
throw new Error('At least one OTP reset option is required');
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
const normalizedEmail = normalizeOtpResetEmail(email);
|
|
778
|
+
const emailHash = hashEmail(normalizedEmail);
|
|
779
|
+
const sbAdmin = await getSupabaseAdmin();
|
|
780
|
+
|
|
781
|
+
if (!sbAdmin) {
|
|
782
|
+
throw new Error('Failed to create Supabase client');
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
let relatedIps: string[] = [];
|
|
786
|
+
if (clearRelatedIpCounters || clearRelatedIpBlocks) {
|
|
787
|
+
const sinceIso = new Date(
|
|
788
|
+
Date.now() - WINDOW_MS.TWENTY_FOUR_HOURS
|
|
789
|
+
).toISOString();
|
|
790
|
+
const { data: relatedEvents, error: relatedEventsError } = await sbAdmin
|
|
791
|
+
.from('abuse_events')
|
|
792
|
+
.select('ip_address')
|
|
793
|
+
.eq('email', normalizedEmail)
|
|
794
|
+
.in('event_type', ['otp_send', 'otp_verify_failed'])
|
|
795
|
+
.gte('created_at', sinceIso);
|
|
796
|
+
|
|
797
|
+
if (relatedEventsError) {
|
|
798
|
+
throw relatedEventsError;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
relatedIps = Array.from(
|
|
802
|
+
new Set(
|
|
803
|
+
(relatedEvents ?? [])
|
|
804
|
+
.map((event: { ip_address: string | null }) => event.ip_address)
|
|
805
|
+
.filter((value: string | null): value is string => !!value)
|
|
806
|
+
)
|
|
807
|
+
);
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
let clearedEmailKeys = 0;
|
|
811
|
+
if (clearEmailScoped) {
|
|
812
|
+
clearedEmailKeys = await deleteKeysWithCount(
|
|
813
|
+
REDIS_KEYS.OTP_SEND_EMAIL_COOLDOWN(emailHash),
|
|
814
|
+
REDIS_KEYS.OTP_SEND_EMAIL_HOURLY(emailHash),
|
|
815
|
+
REDIS_KEYS.OTP_SEND_EMAIL_DAILY(emailHash),
|
|
816
|
+
REDIS_KEYS.OTP_VERIFY_FAILED_EMAIL(emailHash)
|
|
817
|
+
);
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
let clearedIpCounterCount = 0;
|
|
821
|
+
if (clearRelatedIpCounters && relatedIps.length > 0) {
|
|
822
|
+
const relatedIpKeys = relatedIps.flatMap((ipAddress) =>
|
|
823
|
+
getOtpResetScopeKeys(ipAddress)
|
|
824
|
+
);
|
|
825
|
+
clearedIpCounterCount = await deleteKeysWithCount(...relatedIpKeys);
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
let unblockedIpCount = 0;
|
|
829
|
+
let unblockedIps: string[] = [];
|
|
830
|
+
if (clearRelatedIpBlocks && relatedIps.length > 0) {
|
|
831
|
+
const { data: activeBlocks, error: activeBlocksError } = await sbAdmin
|
|
832
|
+
.from('blocked_ips')
|
|
833
|
+
.select('ip_address')
|
|
834
|
+
.in('ip_address', relatedIps)
|
|
835
|
+
.in('reason', ['otp_send', 'otp_verify_failed'])
|
|
836
|
+
.eq('status', 'active');
|
|
837
|
+
|
|
838
|
+
if (activeBlocksError) {
|
|
839
|
+
throw activeBlocksError;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
unblockedIps = Array.from(
|
|
843
|
+
new Set(
|
|
844
|
+
(activeBlocks ?? [])
|
|
845
|
+
.map((block: { ip_address: string | null }) => block.ip_address)
|
|
846
|
+
.filter((value: string | null): value is string => !!value)
|
|
847
|
+
)
|
|
848
|
+
);
|
|
849
|
+
|
|
850
|
+
if (unblockedIps.length > 0) {
|
|
851
|
+
const { error: unblockError } = await sbAdmin
|
|
852
|
+
.from('blocked_ips')
|
|
853
|
+
.update({
|
|
854
|
+
status: 'manually_unblocked',
|
|
855
|
+
unblocked_at: new Date().toISOString(),
|
|
856
|
+
unblocked_by: adminUserId,
|
|
857
|
+
unblock_reason:
|
|
858
|
+
reason || 'Manual OTP limit reset unblock by infrastructure admin',
|
|
859
|
+
})
|
|
860
|
+
.in('ip_address', unblockedIps)
|
|
861
|
+
.in('reason', ['otp_send', 'otp_verify_failed'])
|
|
862
|
+
.eq('status', 'active');
|
|
863
|
+
|
|
864
|
+
if (unblockError) {
|
|
865
|
+
throw unblockError;
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
await deleteKeysWithCount(
|
|
869
|
+
...unblockedIps.flatMap((ipAddress) => [
|
|
870
|
+
REDIS_KEYS.IP_BLOCKED(ipAddress),
|
|
871
|
+
REDIS_KEYS.IP_BLOCK_LEVEL(ipAddress),
|
|
872
|
+
])
|
|
873
|
+
);
|
|
874
|
+
unblockedIpCount = unblockedIps.length;
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
await logAbuseEvent(adminIpAddress || 'unknown', 'otp_limit_reset', {
|
|
879
|
+
email: normalizedEmail,
|
|
880
|
+
success: true,
|
|
881
|
+
metadata: {
|
|
882
|
+
admin_user_id: adminUserId,
|
|
883
|
+
reason: reason || null,
|
|
884
|
+
clearEmailScoped,
|
|
885
|
+
clearRelatedIpCounters,
|
|
886
|
+
clearRelatedIpBlocks,
|
|
887
|
+
related_ips: relatedIps,
|
|
888
|
+
related_ips_count: relatedIps.length,
|
|
889
|
+
cleared_email_keys: clearedEmailKeys,
|
|
890
|
+
cleared_ip_counter_count: clearedIpCounterCount,
|
|
891
|
+
unblocked_ips: unblockedIps,
|
|
892
|
+
unblocked_ip_count: unblockedIpCount,
|
|
893
|
+
},
|
|
894
|
+
});
|
|
895
|
+
|
|
896
|
+
return {
|
|
897
|
+
relatedIps,
|
|
898
|
+
clearedEmailKeys,
|
|
899
|
+
clearedIpCounterCount,
|
|
900
|
+
unblockedIpCount,
|
|
901
|
+
};
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
/**
|
|
905
|
+
* Check and track OTP send attempts
|
|
906
|
+
*/
|
|
907
|
+
export async function checkOTPSendAllowed(
|
|
908
|
+
ipAddress: string,
|
|
909
|
+
email?: string,
|
|
910
|
+
context?: AbuseProtectionLogContext
|
|
911
|
+
): Promise<AbuseCheckResult> {
|
|
912
|
+
// First check if IP is blocked
|
|
913
|
+
const blockInfo = await isIPBlocked(ipAddress, context);
|
|
914
|
+
if (blockInfo) {
|
|
915
|
+
const retryAfter = Math.ceil(
|
|
916
|
+
(blockInfo.expiresAt.getTime() - Date.now()) / 1000
|
|
917
|
+
);
|
|
918
|
+
return {
|
|
919
|
+
allowed: false,
|
|
920
|
+
blocked: true,
|
|
921
|
+
reason: `IP blocked due to ${blockInfo.reason}. Block level: ${blockInfo.blockLevel}`,
|
|
922
|
+
retryAfter,
|
|
923
|
+
};
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
const minuteKey = REDIS_KEYS.OTP_SEND(ipAddress);
|
|
927
|
+
const hourlyKey = REDIS_KEYS.OTP_SEND_HOURLY(ipAddress);
|
|
928
|
+
const dailyKey = REDIS_KEYS.OTP_SEND_DAILY(ipAddress);
|
|
929
|
+
|
|
930
|
+
const [minuteState, hourlyState, dailyState] = await Promise.all([
|
|
931
|
+
getCounterWithTTL(minuteKey),
|
|
932
|
+
getCounterWithTTL(hourlyKey),
|
|
933
|
+
getCounterWithTTL(dailyKey),
|
|
934
|
+
]);
|
|
935
|
+
const ipLimits = getOTPSendIpLimits();
|
|
936
|
+
|
|
937
|
+
if (minuteState.count >= ipLimits.perMinute) {
|
|
938
|
+
// Log and potentially block
|
|
939
|
+
void logAbuseEvent(ipAddress, 'otp_send', {
|
|
940
|
+
email,
|
|
941
|
+
success: false,
|
|
942
|
+
metadata: { trigger: 'minute_limit' },
|
|
943
|
+
});
|
|
944
|
+
|
|
945
|
+
if (minuteState.count >= ipLimits.perMinute * 2) {
|
|
946
|
+
// Aggressive abuse - block IP
|
|
947
|
+
void blockIP(ipAddress, 'otp_send', { trigger: 'rate_limit_exceeded' });
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
return {
|
|
951
|
+
allowed: false,
|
|
952
|
+
reason: 'Too many OTP requests. Please try again later.',
|
|
953
|
+
retryAfter: minuteState.ttl,
|
|
954
|
+
remainingAttempts: 0,
|
|
955
|
+
};
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
if (hourlyState.count >= ipLimits.perHour) {
|
|
959
|
+
void logAbuseEvent(ipAddress, 'otp_send', {
|
|
960
|
+
email,
|
|
961
|
+
success: false,
|
|
962
|
+
metadata: { trigger: 'hourly_rate_limit' },
|
|
963
|
+
});
|
|
964
|
+
|
|
965
|
+
void blockIP(ipAddress, 'otp_send', { trigger: 'hourly_rate_limit' });
|
|
966
|
+
|
|
967
|
+
return {
|
|
968
|
+
allowed: false,
|
|
969
|
+
reason: 'Hourly OTP limit reached. Please try again later.',
|
|
970
|
+
retryAfter: hourlyState.ttl,
|
|
971
|
+
remainingAttempts: 0,
|
|
972
|
+
};
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
if (dailyState.count >= ipLimits.perDay) {
|
|
976
|
+
void logAbuseEvent(ipAddress, 'otp_send', {
|
|
977
|
+
email,
|
|
978
|
+
success: false,
|
|
979
|
+
metadata: { trigger: 'ip_daily_limit' },
|
|
980
|
+
});
|
|
981
|
+
void blockIP(ipAddress, 'otp_send', { trigger: 'ip_daily_limit' });
|
|
982
|
+
|
|
983
|
+
return {
|
|
984
|
+
allowed: false,
|
|
985
|
+
reason: 'OTP limit reached. Please try again later.',
|
|
986
|
+
retryAfter: dailyState.ttl,
|
|
987
|
+
remainingAttempts: 0,
|
|
988
|
+
};
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
if (email) {
|
|
992
|
+
const emailHash = hashEmail(email);
|
|
993
|
+
|
|
994
|
+
const cooldownKey = REDIS_KEYS.OTP_SEND_EMAIL_COOLDOWN(emailHash);
|
|
995
|
+
const hourlyEmailKey = REDIS_KEYS.OTP_SEND_EMAIL_HOURLY(emailHash);
|
|
996
|
+
const dailyEmailKey = REDIS_KEYS.OTP_SEND_EMAIL_DAILY(emailHash);
|
|
997
|
+
|
|
998
|
+
const [cooldownState, hourlyEmailState, dailyEmailState] =
|
|
999
|
+
await Promise.all([
|
|
1000
|
+
getCounterWithTTL(cooldownKey),
|
|
1001
|
+
getCounterWithTTL(hourlyEmailKey),
|
|
1002
|
+
getCounterWithTTL(dailyEmailKey),
|
|
1003
|
+
]);
|
|
1004
|
+
|
|
1005
|
+
if (cooldownState.count >= 1) {
|
|
1006
|
+
void logAbuseEvent(ipAddress, 'otp_send', {
|
|
1007
|
+
email,
|
|
1008
|
+
success: false,
|
|
1009
|
+
metadata: { trigger: 'email_cooldown' },
|
|
1010
|
+
});
|
|
1011
|
+
|
|
1012
|
+
return {
|
|
1013
|
+
allowed: false,
|
|
1014
|
+
reason: 'Too many OTP requests. Please try again later.',
|
|
1015
|
+
retryAfter: cooldownState.ttl,
|
|
1016
|
+
remainingAttempts: 0,
|
|
1017
|
+
};
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
if (hourlyEmailState.count >= ABUSE_THRESHOLDS.OTP_SEND_EMAIL_PER_HOUR) {
|
|
1021
|
+
void logAbuseEvent(ipAddress, 'otp_send', {
|
|
1022
|
+
email,
|
|
1023
|
+
success: false,
|
|
1024
|
+
metadata: { trigger: 'email_hourly_limit' },
|
|
1025
|
+
});
|
|
1026
|
+
|
|
1027
|
+
return {
|
|
1028
|
+
allowed: false,
|
|
1029
|
+
reason: 'Hourly OTP limit reached. Please try again later.',
|
|
1030
|
+
retryAfter: hourlyEmailState.ttl,
|
|
1031
|
+
remainingAttempts: 0,
|
|
1032
|
+
};
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
if (dailyEmailState.count >= ABUSE_THRESHOLDS.OTP_SEND_EMAIL_PER_DAY) {
|
|
1036
|
+
void logAbuseEvent(ipAddress, 'otp_send', {
|
|
1037
|
+
email,
|
|
1038
|
+
success: false,
|
|
1039
|
+
metadata: { trigger: 'email_daily_limit' },
|
|
1040
|
+
});
|
|
1041
|
+
|
|
1042
|
+
return {
|
|
1043
|
+
allowed: false,
|
|
1044
|
+
reason: 'OTP limit reached. Please try again later.',
|
|
1045
|
+
retryAfter: dailyEmailState.ttl,
|
|
1046
|
+
remainingAttempts: 0,
|
|
1047
|
+
};
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
return {
|
|
1052
|
+
allowed: true,
|
|
1053
|
+
remainingAttempts: Math.max(
|
|
1054
|
+
0,
|
|
1055
|
+
ipLimits.perMinute - (minuteState.count + 1)
|
|
1056
|
+
),
|
|
1057
|
+
};
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
export async function recordOTPSendSuccess(
|
|
1061
|
+
ipAddress: string,
|
|
1062
|
+
email?: string
|
|
1063
|
+
): Promise<void> {
|
|
1064
|
+
await Promise.all([
|
|
1065
|
+
incrementCounter(REDIS_KEYS.OTP_SEND(ipAddress), WINDOW_MS.ONE_MINUTE),
|
|
1066
|
+
incrementCounter(REDIS_KEYS.OTP_SEND_HOURLY(ipAddress), WINDOW_MS.ONE_HOUR),
|
|
1067
|
+
incrementCounter(
|
|
1068
|
+
REDIS_KEYS.OTP_SEND_DAILY(ipAddress),
|
|
1069
|
+
WINDOW_MS.TWENTY_FOUR_HOURS
|
|
1070
|
+
),
|
|
1071
|
+
...(email
|
|
1072
|
+
? [
|
|
1073
|
+
incrementCounter(
|
|
1074
|
+
REDIS_KEYS.OTP_SEND_EMAIL_COOLDOWN(hashEmail(email)),
|
|
1075
|
+
ABUSE_THRESHOLDS.OTP_SEND_EMAIL_COOLDOWN_WINDOW_MS
|
|
1076
|
+
),
|
|
1077
|
+
incrementCounter(
|
|
1078
|
+
REDIS_KEYS.OTP_SEND_EMAIL_HOURLY(hashEmail(email)),
|
|
1079
|
+
WINDOW_MS.ONE_HOUR
|
|
1080
|
+
),
|
|
1081
|
+
incrementCounter(
|
|
1082
|
+
REDIS_KEYS.OTP_SEND_EMAIL_DAILY(hashEmail(email)),
|
|
1083
|
+
WINDOW_MS.TWENTY_FOUR_HOURS
|
|
1084
|
+
),
|
|
1085
|
+
]
|
|
1086
|
+
: []),
|
|
1087
|
+
]);
|
|
1088
|
+
|
|
1089
|
+
void logAbuseEvent(ipAddress, 'otp_send', { email, success: true });
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
// Backward-compatible alias for call sites that still import the old helper.
|
|
1093
|
+
export async function checkOTPSendLimit(
|
|
1094
|
+
ipAddress: string,
|
|
1095
|
+
email?: string,
|
|
1096
|
+
context?: AbuseProtectionLogContext
|
|
1097
|
+
): Promise<AbuseCheckResult> {
|
|
1098
|
+
return checkOTPSendAllowed(ipAddress, email, context);
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
/**
|
|
1102
|
+
* Check if OTP verification is allowed
|
|
1103
|
+
*/
|
|
1104
|
+
export async function checkOTPVerifyLimit(
|
|
1105
|
+
ipAddress: string,
|
|
1106
|
+
email: string,
|
|
1107
|
+
context?: AbuseProtectionLogContext
|
|
1108
|
+
): Promise<AbuseCheckResult> {
|
|
1109
|
+
// First check if IP is blocked
|
|
1110
|
+
const blockInfo = await isIPBlocked(ipAddress, context);
|
|
1111
|
+
if (blockInfo) {
|
|
1112
|
+
const retryAfter = Math.ceil(
|
|
1113
|
+
(blockInfo.expiresAt.getTime() - Date.now()) / 1000
|
|
1114
|
+
);
|
|
1115
|
+
return {
|
|
1116
|
+
allowed: false,
|
|
1117
|
+
blocked: true,
|
|
1118
|
+
reason: `IP blocked due to ${blockInfo.reason}`,
|
|
1119
|
+
retryAfter,
|
|
1120
|
+
};
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
// Get current counts (don't increment yet - increment on failure)
|
|
1124
|
+
const ipKey = REDIS_KEYS.OTP_VERIFY_FAILED(ipAddress);
|
|
1125
|
+
const emailKey = REDIS_KEYS.OTP_VERIFY_FAILED_EMAIL(hashEmail(email));
|
|
1126
|
+
|
|
1127
|
+
const [ipCount, emailCount] = await Promise.all([
|
|
1128
|
+
getCounter(ipKey),
|
|
1129
|
+
getCounter(emailKey),
|
|
1130
|
+
]);
|
|
1131
|
+
|
|
1132
|
+
if (ipCount >= ABUSE_THRESHOLDS.OTP_VERIFY_FAILED_MAX) {
|
|
1133
|
+
return {
|
|
1134
|
+
allowed: false,
|
|
1135
|
+
reason: 'Too many failed verification attempts from this IP',
|
|
1136
|
+
retryAfter: Math.ceil(
|
|
1137
|
+
ABUSE_THRESHOLDS.OTP_VERIFY_FAILED_WINDOW_MS / 1000
|
|
1138
|
+
),
|
|
1139
|
+
remainingAttempts: 0,
|
|
1140
|
+
};
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
if (emailCount >= ABUSE_THRESHOLDS.OTP_VERIFY_FAILED_EMAIL_MAX) {
|
|
1144
|
+
return {
|
|
1145
|
+
allowed: false,
|
|
1146
|
+
reason: 'Too many failed verification attempts for this email',
|
|
1147
|
+
retryAfter: Math.ceil(
|
|
1148
|
+
ABUSE_THRESHOLDS.OTP_VERIFY_FAILED_EMAIL_WINDOW_MS / 1000
|
|
1149
|
+
),
|
|
1150
|
+
remainingAttempts: 0,
|
|
1151
|
+
};
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
return {
|
|
1155
|
+
allowed: true,
|
|
1156
|
+
remainingAttempts: ABUSE_THRESHOLDS.OTP_VERIFY_FAILED_MAX - ipCount,
|
|
1157
|
+
};
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
/**
|
|
1161
|
+
* Record a failed OTP verification attempt
|
|
1162
|
+
*/
|
|
1163
|
+
export async function recordOTPVerifyFailure(
|
|
1164
|
+
ipAddress: string,
|
|
1165
|
+
email: string
|
|
1166
|
+
): Promise<void> {
|
|
1167
|
+
const ipKey = REDIS_KEYS.OTP_VERIFY_FAILED(ipAddress);
|
|
1168
|
+
const emailKey = REDIS_KEYS.OTP_VERIFY_FAILED_EMAIL(hashEmail(email));
|
|
1169
|
+
|
|
1170
|
+
const [{ count: ipCount }] = await Promise.all([
|
|
1171
|
+
incrementCounter(ipKey, ABUSE_THRESHOLDS.OTP_VERIFY_FAILED_WINDOW_MS),
|
|
1172
|
+
incrementCounter(
|
|
1173
|
+
emailKey,
|
|
1174
|
+
ABUSE_THRESHOLDS.OTP_VERIFY_FAILED_EMAIL_WINDOW_MS
|
|
1175
|
+
),
|
|
1176
|
+
]);
|
|
1177
|
+
|
|
1178
|
+
// Log the failure
|
|
1179
|
+
void logAbuseEvent(ipAddress, 'otp_verify_failed', { email, success: false });
|
|
1180
|
+
|
|
1181
|
+
// Block if threshold exceeded
|
|
1182
|
+
if (ipCount >= ABUSE_THRESHOLDS.OTP_VERIFY_FAILED_MAX) {
|
|
1183
|
+
void blockIP(ipAddress, 'otp_verify_failed', {
|
|
1184
|
+
trigger: 'max_failures_exceeded',
|
|
1185
|
+
failedCount: ipCount,
|
|
1186
|
+
});
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
/**
|
|
1191
|
+
* Clear failed attempts on successful verification
|
|
1192
|
+
*/
|
|
1193
|
+
export async function clearOTPVerifyFailures(
|
|
1194
|
+
ipAddress: string,
|
|
1195
|
+
email: string
|
|
1196
|
+
): Promise<void> {
|
|
1197
|
+
await deleteKeys(
|
|
1198
|
+
REDIS_KEYS.OTP_VERIFY_FAILED(ipAddress),
|
|
1199
|
+
REDIS_KEYS.OTP_VERIFY_FAILED_EMAIL(hashEmail(email))
|
|
1200
|
+
);
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
/**
|
|
1204
|
+
* Check and track MFA challenge attempts
|
|
1205
|
+
*/
|
|
1206
|
+
export async function checkMFAChallengeLimit(
|
|
1207
|
+
ipAddress: string
|
|
1208
|
+
): Promise<AbuseCheckResult> {
|
|
1209
|
+
const blockInfo = await isIPBlocked(ipAddress);
|
|
1210
|
+
if (blockInfo) {
|
|
1211
|
+
return {
|
|
1212
|
+
allowed: false,
|
|
1213
|
+
blocked: true,
|
|
1214
|
+
reason: `IP blocked due to ${blockInfo.reason}`,
|
|
1215
|
+
retryAfter: Math.ceil(
|
|
1216
|
+
(blockInfo.expiresAt.getTime() - Date.now()) / 1000
|
|
1217
|
+
),
|
|
1218
|
+
};
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
const key = REDIS_KEYS.MFA_CHALLENGE(ipAddress);
|
|
1222
|
+
const { count, ttl } = await incrementCounter(key, WINDOW_MS.ONE_MINUTE);
|
|
1223
|
+
|
|
1224
|
+
if (count > ABUSE_THRESHOLDS.MFA_CHALLENGE_PER_MINUTE) {
|
|
1225
|
+
void logAbuseEvent(ipAddress, 'mfa_challenge', { success: false });
|
|
1226
|
+
return {
|
|
1227
|
+
allowed: false,
|
|
1228
|
+
reason: 'Too many MFA challenge requests',
|
|
1229
|
+
retryAfter: ttl,
|
|
1230
|
+
remainingAttempts: 0,
|
|
1231
|
+
};
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
return { allowed: true };
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
/**
|
|
1238
|
+
* Check if MFA verification is allowed
|
|
1239
|
+
*/
|
|
1240
|
+
export async function checkMFAVerifyLimit(
|
|
1241
|
+
ipAddress: string
|
|
1242
|
+
): Promise<AbuseCheckResult> {
|
|
1243
|
+
const blockInfo = await isIPBlocked(ipAddress);
|
|
1244
|
+
if (blockInfo) {
|
|
1245
|
+
return {
|
|
1246
|
+
allowed: false,
|
|
1247
|
+
blocked: true,
|
|
1248
|
+
reason: `IP blocked due to ${blockInfo.reason}`,
|
|
1249
|
+
retryAfter: Math.ceil(
|
|
1250
|
+
(blockInfo.expiresAt.getTime() - Date.now()) / 1000
|
|
1251
|
+
),
|
|
1252
|
+
};
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
const key = REDIS_KEYS.MFA_VERIFY_FAILED(ipAddress);
|
|
1256
|
+
const count = await getCounter(key);
|
|
1257
|
+
|
|
1258
|
+
if (count >= ABUSE_THRESHOLDS.MFA_VERIFY_FAILED_MAX) {
|
|
1259
|
+
return {
|
|
1260
|
+
allowed: false,
|
|
1261
|
+
reason: 'Too many failed MFA verification attempts',
|
|
1262
|
+
retryAfter: Math.ceil(
|
|
1263
|
+
ABUSE_THRESHOLDS.MFA_VERIFY_FAILED_WINDOW_MS / 1000
|
|
1264
|
+
),
|
|
1265
|
+
remainingAttempts: 0,
|
|
1266
|
+
};
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
return {
|
|
1270
|
+
allowed: true,
|
|
1271
|
+
remainingAttempts: ABUSE_THRESHOLDS.MFA_VERIFY_FAILED_MAX - count,
|
|
1272
|
+
};
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
/**
|
|
1276
|
+
* Record a failed MFA verification attempt
|
|
1277
|
+
*/
|
|
1278
|
+
export async function recordMFAVerifyFailure(ipAddress: string): Promise<void> {
|
|
1279
|
+
const key = REDIS_KEYS.MFA_VERIFY_FAILED(ipAddress);
|
|
1280
|
+
const { count } = await incrementCounter(
|
|
1281
|
+
key,
|
|
1282
|
+
ABUSE_THRESHOLDS.MFA_VERIFY_FAILED_WINDOW_MS
|
|
1283
|
+
);
|
|
1284
|
+
|
|
1285
|
+
void logAbuseEvent(ipAddress, 'mfa_verify_failed', { success: false });
|
|
1286
|
+
|
|
1287
|
+
if (count >= ABUSE_THRESHOLDS.MFA_VERIFY_FAILED_MAX) {
|
|
1288
|
+
void blockIP(ipAddress, 'mfa_verify_failed', {
|
|
1289
|
+
trigger: 'max_failures_exceeded',
|
|
1290
|
+
failedCount: count,
|
|
1291
|
+
});
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
/**
|
|
1296
|
+
* Clear MFA failures on success
|
|
1297
|
+
*/
|
|
1298
|
+
export async function clearMFAVerifyFailures(ipAddress: string): Promise<void> {
|
|
1299
|
+
await deleteKeys(REDIS_KEYS.MFA_VERIFY_FAILED(ipAddress));
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
/**
|
|
1303
|
+
* Check reauthentication send limits
|
|
1304
|
+
*/
|
|
1305
|
+
export async function checkReauthSendLimit(
|
|
1306
|
+
ipAddress: string
|
|
1307
|
+
): Promise<AbuseCheckResult> {
|
|
1308
|
+
const blockInfo = await isIPBlocked(ipAddress);
|
|
1309
|
+
if (blockInfo) {
|
|
1310
|
+
return {
|
|
1311
|
+
allowed: false,
|
|
1312
|
+
blocked: true,
|
|
1313
|
+
reason: `IP blocked due to ${blockInfo.reason}`,
|
|
1314
|
+
retryAfter: Math.ceil(
|
|
1315
|
+
(blockInfo.expiresAt.getTime() - Date.now()) / 1000
|
|
1316
|
+
),
|
|
1317
|
+
};
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
const key = REDIS_KEYS.REAUTH_SEND(ipAddress);
|
|
1321
|
+
const { count, ttl } = await incrementCounter(key, WINDOW_MS.ONE_MINUTE);
|
|
1322
|
+
|
|
1323
|
+
if (count > ABUSE_THRESHOLDS.REAUTH_SEND_PER_MINUTE) {
|
|
1324
|
+
void logAbuseEvent(ipAddress, 'reauth_send', { success: false });
|
|
1325
|
+
return {
|
|
1326
|
+
allowed: false,
|
|
1327
|
+
reason: 'Too many reauthentication requests',
|
|
1328
|
+
retryAfter: ttl,
|
|
1329
|
+
remainingAttempts: 0,
|
|
1330
|
+
};
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
return { allowed: true };
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
/**
|
|
1337
|
+
* Check reauthentication verify limits
|
|
1338
|
+
*/
|
|
1339
|
+
export async function checkReauthVerifyLimit(
|
|
1340
|
+
ipAddress: string
|
|
1341
|
+
): Promise<AbuseCheckResult> {
|
|
1342
|
+
const blockInfo = await isIPBlocked(ipAddress);
|
|
1343
|
+
if (blockInfo) {
|
|
1344
|
+
return {
|
|
1345
|
+
allowed: false,
|
|
1346
|
+
blocked: true,
|
|
1347
|
+
reason: `IP blocked due to ${blockInfo.reason}`,
|
|
1348
|
+
retryAfter: Math.ceil(
|
|
1349
|
+
(blockInfo.expiresAt.getTime() - Date.now()) / 1000
|
|
1350
|
+
),
|
|
1351
|
+
};
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
const key = REDIS_KEYS.REAUTH_VERIFY_FAILED(ipAddress);
|
|
1355
|
+
const count = await getCounter(key);
|
|
1356
|
+
|
|
1357
|
+
if (count >= ABUSE_THRESHOLDS.REAUTH_VERIFY_FAILED_MAX) {
|
|
1358
|
+
return {
|
|
1359
|
+
allowed: false,
|
|
1360
|
+
reason: 'Too many failed reauthentication attempts',
|
|
1361
|
+
retryAfter: Math.ceil(
|
|
1362
|
+
ABUSE_THRESHOLDS.REAUTH_VERIFY_FAILED_WINDOW_MS / 1000
|
|
1363
|
+
),
|
|
1364
|
+
remainingAttempts: 0,
|
|
1365
|
+
};
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
return {
|
|
1369
|
+
allowed: true,
|
|
1370
|
+
remainingAttempts: ABUSE_THRESHOLDS.REAUTH_VERIFY_FAILED_MAX - count,
|
|
1371
|
+
};
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
/**
|
|
1375
|
+
* Record failed reauthentication
|
|
1376
|
+
*/
|
|
1377
|
+
export async function recordReauthVerifyFailure(
|
|
1378
|
+
ipAddress: string
|
|
1379
|
+
): Promise<void> {
|
|
1380
|
+
const key = REDIS_KEYS.REAUTH_VERIFY_FAILED(ipAddress);
|
|
1381
|
+
const { count } = await incrementCounter(
|
|
1382
|
+
key,
|
|
1383
|
+
ABUSE_THRESHOLDS.REAUTH_VERIFY_FAILED_WINDOW_MS
|
|
1384
|
+
);
|
|
1385
|
+
|
|
1386
|
+
void logAbuseEvent(ipAddress, 'reauth_verify_failed', { success: false });
|
|
1387
|
+
|
|
1388
|
+
if (count >= ABUSE_THRESHOLDS.REAUTH_VERIFY_FAILED_MAX) {
|
|
1389
|
+
void blockIP(ipAddress, 'reauth_verify_failed');
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
/**
|
|
1394
|
+
* Clear reauth failures on success
|
|
1395
|
+
*/
|
|
1396
|
+
export async function clearReauthVerifyFailures(
|
|
1397
|
+
ipAddress: string
|
|
1398
|
+
): Promise<void> {
|
|
1399
|
+
await deleteKeys(REDIS_KEYS.REAUTH_VERIFY_FAILED(ipAddress));
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
/**
|
|
1403
|
+
* Check password login limits
|
|
1404
|
+
*/
|
|
1405
|
+
export async function checkPasswordLoginLimit(
|
|
1406
|
+
ipAddress: string,
|
|
1407
|
+
context?: AbuseProtectionLogContext
|
|
1408
|
+
): Promise<AbuseCheckResult> {
|
|
1409
|
+
const blockInfo = await isIPBlocked(ipAddress, context);
|
|
1410
|
+
if (blockInfo) {
|
|
1411
|
+
return {
|
|
1412
|
+
allowed: false,
|
|
1413
|
+
blocked: true,
|
|
1414
|
+
reason: `IP blocked due to ${blockInfo.reason}`,
|
|
1415
|
+
retryAfter: Math.ceil(
|
|
1416
|
+
(blockInfo.expiresAt.getTime() - Date.now()) / 1000
|
|
1417
|
+
),
|
|
1418
|
+
};
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
const key = REDIS_KEYS.PASSWORD_LOGIN_FAILED(ipAddress);
|
|
1422
|
+
const count = await getCounter(key);
|
|
1423
|
+
|
|
1424
|
+
if (count >= ABUSE_THRESHOLDS.PASSWORD_LOGIN_FAILED_MAX) {
|
|
1425
|
+
return {
|
|
1426
|
+
allowed: false,
|
|
1427
|
+
reason: 'Too many failed login attempts',
|
|
1428
|
+
retryAfter: Math.ceil(
|
|
1429
|
+
ABUSE_THRESHOLDS.PASSWORD_LOGIN_FAILED_WINDOW_MS / 1000
|
|
1430
|
+
),
|
|
1431
|
+
remainingAttempts: 0,
|
|
1432
|
+
};
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
return {
|
|
1436
|
+
allowed: true,
|
|
1437
|
+
remainingAttempts: ABUSE_THRESHOLDS.PASSWORD_LOGIN_FAILED_MAX - count,
|
|
1438
|
+
};
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
/**
|
|
1442
|
+
* Record failed password login
|
|
1443
|
+
*/
|
|
1444
|
+
export async function recordPasswordLoginFailure(
|
|
1445
|
+
ipAddress: string,
|
|
1446
|
+
email?: string
|
|
1447
|
+
): Promise<void> {
|
|
1448
|
+
const key = REDIS_KEYS.PASSWORD_LOGIN_FAILED(ipAddress);
|
|
1449
|
+
const { count } = await incrementCounter(
|
|
1450
|
+
key,
|
|
1451
|
+
ABUSE_THRESHOLDS.PASSWORD_LOGIN_FAILED_WINDOW_MS
|
|
1452
|
+
);
|
|
1453
|
+
|
|
1454
|
+
void logAbuseEvent(ipAddress, 'password_login_failed', {
|
|
1455
|
+
email,
|
|
1456
|
+
success: false,
|
|
1457
|
+
});
|
|
1458
|
+
|
|
1459
|
+
if (count >= ABUSE_THRESHOLDS.PASSWORD_LOGIN_FAILED_MAX) {
|
|
1460
|
+
void blockIP(ipAddress, 'password_login_failed', {
|
|
1461
|
+
trigger: 'max_failures_exceeded',
|
|
1462
|
+
failedCount: count,
|
|
1463
|
+
});
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
/**
|
|
1468
|
+
* Clear password login failures on success
|
|
1469
|
+
*/
|
|
1470
|
+
export async function clearPasswordLoginFailures(
|
|
1471
|
+
ipAddress: string
|
|
1472
|
+
): Promise<void> {
|
|
1473
|
+
await deleteKeys(REDIS_KEYS.PASSWORD_LOGIN_FAILED(ipAddress));
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
/**
|
|
1477
|
+
* Check if an IP should be blocked for API auth abuse
|
|
1478
|
+
*/
|
|
1479
|
+
export async function checkApiAuthLimit(
|
|
1480
|
+
ipAddress: string,
|
|
1481
|
+
context?: AbuseProtectionLogContext
|
|
1482
|
+
): Promise<AbuseCheckResult> {
|
|
1483
|
+
const blockInfo = await isIPBlocked(ipAddress, context);
|
|
1484
|
+
if (blockInfo) {
|
|
1485
|
+
return {
|
|
1486
|
+
allowed: false,
|
|
1487
|
+
blocked: true,
|
|
1488
|
+
reason: `IP blocked due to ${blockInfo.reason}`,
|
|
1489
|
+
retryAfter: Math.ceil(
|
|
1490
|
+
(blockInfo.expiresAt.getTime() - Date.now()) / 1000
|
|
1491
|
+
),
|
|
1492
|
+
};
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
const key = REDIS_KEYS.API_AUTH_FAILED(ipAddress);
|
|
1496
|
+
const count = await getCounter(key);
|
|
1497
|
+
|
|
1498
|
+
if (count >= ABUSE_THRESHOLDS.API_AUTH_FAILED_MAX) {
|
|
1499
|
+
return {
|
|
1500
|
+
allowed: false,
|
|
1501
|
+
reason: 'Too many failed API authentication attempts',
|
|
1502
|
+
retryAfter: Math.ceil(ABUSE_THRESHOLDS.API_AUTH_FAILED_WINDOW_MS / 1000),
|
|
1503
|
+
remainingAttempts: 0,
|
|
1504
|
+
};
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
return {
|
|
1508
|
+
allowed: true,
|
|
1509
|
+
remainingAttempts: ABUSE_THRESHOLDS.API_AUTH_FAILED_MAX - count,
|
|
1510
|
+
};
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
/**
|
|
1514
|
+
* Record a failed API auth attempt. Auto-blocks IP if threshold exceeded.
|
|
1515
|
+
*/
|
|
1516
|
+
export async function recordApiAuthFailure(
|
|
1517
|
+
ipAddress: string,
|
|
1518
|
+
endpoint?: string
|
|
1519
|
+
): Promise<void> {
|
|
1520
|
+
const key = REDIS_KEYS.API_AUTH_FAILED(ipAddress);
|
|
1521
|
+
const { count } = await incrementCounter(
|
|
1522
|
+
key,
|
|
1523
|
+
ABUSE_THRESHOLDS.API_AUTH_FAILED_WINDOW_MS
|
|
1524
|
+
);
|
|
1525
|
+
|
|
1526
|
+
void logAbuseEvent(ipAddress, 'api_auth_failed', {
|
|
1527
|
+
endpoint,
|
|
1528
|
+
success: false,
|
|
1529
|
+
});
|
|
1530
|
+
|
|
1531
|
+
if (count >= ABUSE_THRESHOLDS.API_AUTH_FAILED_MAX) {
|
|
1532
|
+
void blockIP(ipAddress, 'api_auth_failed', {
|
|
1533
|
+
trigger: 'max_failures_exceeded',
|
|
1534
|
+
failedCount: count,
|
|
1535
|
+
endpoint,
|
|
1536
|
+
});
|
|
1537
|
+
}
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
/**
|
|
1541
|
+
* Clear API auth failures (e.g. on successful auth from that IP)
|
|
1542
|
+
*/
|
|
1543
|
+
export async function clearApiAuthFailures(ipAddress: string): Promise<void> {
|
|
1544
|
+
await deleteKeys(REDIS_KEYS.API_AUTH_FAILED(ipAddress));
|
|
1545
|
+
}
|