Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 | 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 5x 5x 5x 5x 5x 5x 5x 5x 5x 5x 5x 5x 5x 5x 5x 5x 1x 1x 1x 4x 4x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 1x 1x 2x 2x 2x 2x 2x 2x 2x 8x 8x 2x 2x 2x 2x 2x 2x 1x 1x 2x 1x 13x 13x 13x 13x 13x 13x 1x 6x 6x 6x 6x 3x 3x 1x 3x 2x 2x 2x 6x | /**
* Authentication Rate Limiting
*
* Redis-first with DB fallback.
* If Upstash Redis is configured, uses sliding window via @upstash/ratelimit.
* Otherwise, falls back to Supabase RPC-based rate limiting.
*/
import { getAdminClient } from '@/lib/supabase/admin';
import { checkRedisRateLimit, resetRedisRateLimit } from '@/lib/redis/rate-limit';
export type RateLimitType = 'phone' | 'email' | 'ip';
export interface RateLimitResult {
allowed: boolean;
blocked_until?: string;
attempts: number;
}
/**
* Rate limit configuration by type
*/
export const RATE_LIMIT_CONFIG = {
otp_send: {
maxAttempts: 3,
windowMinutes: 10,
},
otp_verify: {
maxAttempts: 5,
windowMinutes: 15,
},
magic_link: {
maxAttempts: 5,
windowMinutes: 60,
},
password_login: {
maxAttempts: 5,
windowMinutes: 15,
},
};
/**
* Check if an identifier is rate limited.
* Tries Redis first, falls back to DB.
*/
export async function checkRateLimit(
identifier: string,
identifierType: RateLimitType,
configKey: keyof typeof RATE_LIMIT_CONFIG = 'otp_send'
): Promise<RateLimitResult> {
// Try Redis first
const redisResult = await checkRedisRateLimit(`${identifierType}:${identifier}`, configKey);
if (redisResult !== null) {
const config = RATE_LIMIT_CONFIG[configKey];
return {
allowed: redisResult.allowed,
blocked_until: redisResult.allowed ? undefined : redisResult.resetAt.toISOString(),
attempts: config.maxAttempts - redisResult.remaining,
};
}
// Fallback to DB
const adminClient = getAdminClient();
const config = RATE_LIMIT_CONFIG[configKey];
const { data, error } = await adminClient.rpc('check_otp_rate_limit', {
p_identifier: identifier,
p_identifier_type: identifierType,
p_max_attempts: config.maxAttempts,
p_window_minutes: config.windowMinutes,
});
if (error) {
console.error('Rate limit check error:', error);
// Fail open but log the error
return { allowed: true, attempts: 0 };
}
return data as RateLimitResult;
}
/**
* Increment rate limit counter.
* With Redis, this is handled automatically by checkRateLimit (sliding window).
* Only needed for DB fallback.
*/
export async function incrementRateLimit(
identifier: string,
identifierType: RateLimitType
): Promise<void> {
// Redis handles this automatically via the sliding window in checkRateLimit.
// Only increment in DB as fallback.
const { isRedisAvailable } = await import('@/lib/redis/client');
if (isRedisAvailable()) return;
const adminClient = getAdminClient();
const { error } = await adminClient.rpc('increment_otp_attempt', {
p_identifier: identifier,
p_identifier_type: identifierType,
});
if (error) {
console.error('Rate limit increment error:', error);
}
}
/**
* Clear rate limit after successful authentication
*/
export async function clearRateLimit(
identifier: string,
identifierType: RateLimitType
): Promise<void> {
// Try Redis first
const configKeys = Object.keys(RATE_LIMIT_CONFIG) as (keyof typeof RATE_LIMIT_CONFIG)[];
for (const configKey of configKeys) {
await resetRedisRateLimit(`${identifierType}:${identifier}`, configKey);
}
// Also clear DB (in case of mixed usage during transition)
const adminClient = getAdminClient();
const { error } = await adminClient.rpc('clear_otp_rate_limit', {
p_identifier: identifier,
p_identifier_type: identifierType,
});
if (error) {
console.error('Rate limit clear error:', error);
}
}
/**
* Get remaining attempts before rate limit
*/
export function getRemainingAttempts(
attempts: number,
configKey: keyof typeof RATE_LIMIT_CONFIG = 'otp_send'
): number {
const config = RATE_LIMIT_CONFIG[configKey];
return Math.max(0, config.maxAttempts - attempts);
}
/**
* Format blocked until timestamp for display
*/
export function formatBlockedUntil(blockedUntil: string): string {
const date = new Date(blockedUntil);
const now = new Date();
const diffMinutes = Math.ceil((date.getTime() - now.getTime()) / (1000 * 60));
if (diffMinutes <= 1) {
return 'less than a minute';
} else if (diffMinutes < 60) {
return `${diffMinutes} minutes`;
} else {
const hours = Math.ceil(diffMinutes / 60);
return `${hours} hour${hours > 1 ? 's' : ''}`;
}
}
|