Skip to content

Commit d0a933b

Browse files
committed
Add email based rate limiting to email login API endpoint
Server: - Rate limit based on unverified email before creating user - Check email address for deliverability before creating user - Track rate limit for unverified email in new non-user keyed table Web app: - Show error in login popup to user on failure/throttling - Simplify login popup logic by moving magic link handling logic into EmailSigninContext instead of passing require props via parent
1 parent fe308c2 commit d0a933b

File tree

8 files changed

+199
-124
lines changed

8 files changed

+199
-124
lines changed

src/interface/web/app/components/loginPrompt/loginPrompt.tsx

+43-69
Original file line numberDiff line numberDiff line change
@@ -52,10 +52,6 @@ export default function LoginPrompt(props: LoginPromptProps) {
5252

5353
const [useEmailSignIn, setUseEmailSignIn] = useState(false);
5454

55-
const [email, setEmail] = useState("");
56-
const [checkEmail, setCheckEmail] = useState(false);
57-
const [recheckEmail, setRecheckEmail] = useState(false);
58-
5955
useEffect(() => {
6056
const google = (window as any).google;
6157

@@ -118,49 +114,13 @@ export default function LoginPrompt(props: LoginPromptProps) {
118114
});
119115
};
120116

121-
function handleMagicLinkSignIn() {
122-
fetch("/auth/magic", {
123-
method: "POST",
124-
headers: {
125-
"Content-Type": "application/json",
126-
},
127-
body: JSON.stringify({ email: email }),
128-
})
129-
.then((res) => {
130-
if (res.ok) {
131-
setCheckEmail(true);
132-
if (checkEmail) {
133-
setRecheckEmail(true);
134-
}
135-
return res.json();
136-
} else {
137-
throw new Error("Failed to send magic link");
138-
}
139-
})
140-
.then((data) => {
141-
console.log(data);
142-
})
143-
.catch((err) => {
144-
console.error(err);
145-
});
146-
}
147-
148117
if (props.isMobileWidth) {
149118
return (
150119
<Drawer open={true} onOpenChange={props.onOpenChange}>
151120
<DrawerContent className={`flex flex-col gap-4 w-full mb-4`}>
152121
<div>
153122
{useEmailSignIn ? (
154-
<EmailSignInContext
155-
email={email}
156-
setEmail={setEmail}
157-
checkEmail={checkEmail}
158-
setCheckEmail={setCheckEmail}
159-
setUseEmailSignIn={setUseEmailSignIn}
160-
recheckEmail={recheckEmail}
161-
setRecheckEmail={setRecheckEmail}
162-
handleMagicLinkSignIn={handleMagicLinkSignIn}
163-
/>
123+
<EmailSignInContext setUseEmailSignIn={setUseEmailSignIn} />
164124
) : (
165125
<MainSignInContext
166126
handleGoogleScriptLoad={handleGoogleScriptLoad}
@@ -187,16 +147,7 @@ export default function LoginPrompt(props: LoginPromptProps) {
187147
</VisuallyHidden.Root>
188148
<div>
189149
{useEmailSignIn ? (
190-
<EmailSignInContext
191-
email={email}
192-
setEmail={setEmail}
193-
checkEmail={checkEmail}
194-
setCheckEmail={setCheckEmail}
195-
setUseEmailSignIn={setUseEmailSignIn}
196-
recheckEmail={recheckEmail}
197-
setRecheckEmail={setRecheckEmail}
198-
handleMagicLinkSignIn={handleMagicLinkSignIn}
199-
/>
150+
<EmailSignInContext setUseEmailSignIn={setUseEmailSignIn} />
200151
) : (
201152
<MainSignInContext
202153
handleGoogleScriptLoad={handleGoogleScriptLoad}
@@ -214,26 +165,17 @@ export default function LoginPrompt(props: LoginPromptProps) {
214165
}
215166

216167
function EmailSignInContext({
217-
email,
218-
setEmail,
219-
checkEmail,
220-
setCheckEmail,
221168
setUseEmailSignIn,
222-
recheckEmail,
223-
handleMagicLinkSignIn,
224169
}: {
225-
email: string;
226-
setEmail: (email: string) => void;
227-
checkEmail: boolean;
228-
setCheckEmail: (checkEmail: boolean) => void;
229170
setUseEmailSignIn: (useEmailSignIn: boolean) => void;
230-
recheckEmail: boolean;
231-
setRecheckEmail: (recheckEmail: boolean) => void;
232-
handleMagicLinkSignIn: () => void;
233171
}) {
234172
const [otp, setOTP] = useState("");
235173
const [otpError, setOTPError] = useState("");
236174
const [numFailures, setNumFailures] = useState(0);
175+
const [email, setEmail] = useState("");
176+
const [checkEmail, setCheckEmail] = useState(false);
177+
const [recheckEmail, setRecheckEmail] = useState(false);
178+
const [sendEmailError, setSendEmailError] = useState("");
237179

238180
function checkOTPAndRedirect() {
239181
const verifyUrl = `/auth/magic?code=${encodeURIComponent(otp)}&email=${encodeURIComponent(email)}`;
@@ -275,6 +217,39 @@ function EmailSignInContext({
275217
});
276218
}
277219

220+
function handleMagicLinkSignIn() {
221+
fetch("/auth/magic", {
222+
method: "POST",
223+
headers: {
224+
"Content-Type": "application/json",
225+
},
226+
body: JSON.stringify({ email: email }),
227+
})
228+
.then((res) => {
229+
if (res.ok) {
230+
setCheckEmail(true);
231+
if (checkEmail) {
232+
setRecheckEmail(true);
233+
}
234+
return res.json();
235+
} else if (res.status === 429 || res.status === 404) {
236+
res.json().then((data) => {
237+
setSendEmailError(data.detail);
238+
throw new Error(data.detail);
239+
});
240+
} else {
241+
setSendEmailError("Failed to send email. Contact developers for assistance.");
242+
throw new Error("Failed to send magic link via email.");
243+
}
244+
})
245+
.then((data) => {
246+
console.log(data);
247+
})
248+
.catch((err) => {
249+
console.error(err);
250+
});
251+
}
252+
278253
return (
279254
<div className="flex flex-col gap-4 p-4">
280255
<Button
@@ -297,7 +272,7 @@ function EmailSignInContext({
297272
: "You will receive a sign-in code on the email address you provide below"}
298273
</div>
299274
{!checkEmail && (
300-
<>
275+
<div className="flex items-center justify-center gap-4 text-muted-foreground flex-col">
301276
<Input
302277
placeholder="Email"
303278
className="p-6 w-[300px] mx-auto rounded-lg"
@@ -320,7 +295,8 @@ function EmailSignInContext({
320295
<PaperPlaneTilt className="h-6 w-6 mr-2 font-bold" />
321296
{checkEmail ? "Check your email" : "Send sign in code"}
322297
</Button>
323-
</>
298+
{sendEmailError && <div className="text-red-500 text-sm">{sendEmailError}</div>}
299+
</div>
324300
)}
325301
{checkEmail && (
326302
<div className="flex items-center justify-center gap-4 text-muted-foreground flex-col">
@@ -359,9 +335,7 @@ function EmailSignInContext({
359335
variant="ghost"
360336
className="p-0 text-orange-500"
361337
disabled={recheckEmail}
362-
onClick={() => {
363-
handleMagicLinkSignIn();
364-
}}
338+
onClick={handleMagicLinkSignIn}
365339
>
366340
<ArrowsClockwise className="h-6 w-6 mr-2 text-gray-500" />
367341
Resend email

src/khoj/configure.py

+5-2
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
aget_or_create_user_by_phone_number,
3838
aget_user_by_phone_number,
3939
ais_user_subscribed,
40+
delete_ratelimit_records,
4041
delete_user_requests,
4142
get_all_users,
4243
get_or_create_search_models,
@@ -428,8 +429,10 @@ def upload_telemetry():
428429
@schedule.repeat(schedule.every(31).minutes)
429430
@clean_connections
430431
def delete_old_user_requests():
431-
num_deleted = delete_user_requests()
432-
logger.debug(f"🗑️ Deleted {num_deleted[0]} day-old user requests")
432+
num_user_ratelimit_requests = delete_user_requests()
433+
num_ratelimit_requests = delete_ratelimit_records()
434+
if state.verbose > 2:
435+
logger.debug(f"🗑️ Deleted {num_user_ratelimit_requests + num_ratelimit_requests} stale rate limit requests")
433436

434437

435438
@schedule.repeat(schedule.every(17).minutes)

src/khoj/database/adapters/__init__.py

+19-6
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
from django.db.models import Prefetch, Q
2828
from django.db.models.manager import BaseManager
2929
from django.db.utils import IntegrityError
30+
from django.utils import timezone as django_timezone
3031
from django_apscheduler import util
3132
from django_apscheduler.models import DjangoJob, DjangoJobExecution
3233
from fastapi import HTTPException
@@ -49,6 +50,7 @@
4950
NotionConfig,
5051
ProcessLock,
5152
PublicConversation,
53+
RateLimitRecord,
5254
ReflectiveQuestion,
5355
SearchModelConfig,
5456
ServerChatSettings,
@@ -233,20 +235,21 @@ async def acreate_user_by_phone_number(phone_number: str) -> KhojUser:
233235
return user
234236

235237

236-
async def aget_or_create_user_by_email(input_email: str) -> tuple[KhojUser, bool]:
237-
email, is_valid_email = normalize_email(input_email)
238+
async def aget_or_create_user_by_email(input_email: str, check_deliverability=False) -> tuple[KhojUser, bool]:
239+
# Validate deliverability to email address of new user
240+
email, is_valid_email = normalize_email(input_email, check_deliverability=check_deliverability)
238241
is_existing_user = await KhojUser.objects.filter(email=email).aexists()
239-
# Validate email address of new users
240242
if not is_existing_user and not is_valid_email:
241243
logger.error(f"Account creation failed. Invalid email address: {email}")
242244
return None, False
243245

246+
# Get/create user based on email address
244247
user, is_new = await KhojUser.objects.filter(email=email).aupdate_or_create(
245248
defaults={"username": email, "email": email}
246249
)
247250

248251
# Generate a secure 6-digit numeric code
249-
user.email_verification_code = f"{secrets.randbelow(1000000):06}"
252+
user.email_verification_code = f"{secrets.randbelow(int(1e6)):06}"
250253
user.email_verification_code_expiry = datetime.now(tz=timezone.utc) + timedelta(minutes=5)
251254
await user.asave()
252255

@@ -516,8 +519,18 @@ def get_user_notion_config(user: KhojUser):
516519
return config
517520

518521

519-
def delete_user_requests(window: timedelta = timedelta(days=1)):
520-
return UserRequests.objects.filter(created_at__lte=datetime.now(tz=timezone.utc) - window).delete()
522+
def delete_user_requests(max_age: timedelta = timedelta(days=1)):
523+
"""Deletes UserRequests entries older than the specified max_age."""
524+
cutoff = django_timezone.now() - max_age
525+
deleted_count, _ = UserRequests.objects.filter(created_at__lte=cutoff).delete()
526+
return deleted_count
527+
528+
529+
def delete_ratelimit_records(max_age: timedelta = timedelta(days=1)):
530+
"""Deletes RateLimitRecord entries older than the specified max_age."""
531+
cutoff = django_timezone.now() - max_age
532+
deleted_count, _ = RateLimitRecord.objects.filter(created_at__lt=cutoff).delete()
533+
return deleted_count
521534

522535

523536
@arequire_valid_user

src/khoj/database/admin.py

+2
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
KhojUser,
2626
NotionConfig,
2727
ProcessLock,
28+
RateLimitRecord,
2829
ReflectiveQuestion,
2930
SearchModelConfig,
3031
ServerChatSettings,
@@ -179,6 +180,7 @@ def get_email_login_url(self, request, queryset):
179180
admin.site.register(UserVoiceModelConfig, unfold_admin.ModelAdmin)
180181
admin.site.register(VoiceModelOption, unfold_admin.ModelAdmin)
181182
admin.site.register(UserRequests, unfold_admin.ModelAdmin)
183+
admin.site.register(RateLimitRecord, unfold_admin.ModelAdmin)
182184

183185

184186
@admin.register(Agent)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# Generated by Django 5.0.13 on 2025-04-07 07:10
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
dependencies = [
8+
("database", "0087_alter_aimodelapi_api_key"),
9+
]
10+
11+
operations = [
12+
migrations.CreateModel(
13+
name="RateLimitRecord",
14+
fields=[
15+
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
16+
("created_at", models.DateTimeField(auto_now_add=True)),
17+
("updated_at", models.DateTimeField(auto_now=True)),
18+
("identifier", models.CharField(db_index=True, max_length=255)),
19+
("slug", models.CharField(db_index=True, max_length=255)),
20+
],
21+
options={
22+
"ordering": ["-created_at"],
23+
"indexes": [
24+
models.Index(fields=["identifier", "slug", "created_at"], name="database_ra_identif_031adf_idx")
25+
],
26+
},
27+
),
28+
]

src/khoj/database/models/__init__.py

+18
Original file line numberDiff line numberDiff line change
@@ -730,10 +730,28 @@ class Meta:
730730

731731

732732
class UserRequests(DbBaseModel):
733+
"""Stores user requests to the server for rate limiting."""
734+
733735
user = models.ForeignKey(KhojUser, on_delete=models.CASCADE)
734736
slug = models.CharField(max_length=200)
735737

736738

739+
class RateLimitRecord(DbBaseModel):
740+
"""Stores individual request timestamps for rate limiting."""
741+
742+
identifier = models.CharField(max_length=255, db_index=True) # IP address or email
743+
slug = models.CharField(max_length=255, db_index=True) # Differentiates limit types
744+
745+
class Meta:
746+
indexes = [
747+
models.Index(fields=["identifier", "slug", "created_at"]),
748+
]
749+
ordering = ["-created_at"]
750+
751+
def __str__(self):
752+
return f"{self.slug} - {self.identifier} at {self.created_at}"
753+
754+
737755
class DataStore(DbBaseModel):
738756
key = models.CharField(max_length=200, unique=True)
739757
value = models.JSONField(default=dict)

0 commit comments

Comments
 (0)