aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/api/users.py
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/api/users.py')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/users.py98
1 files changed, 45 insertions, 53 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/users.py b/packages/meshbay-hub/src/meshbay_hub/api/users.py
index 702d687..7cebd91 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/users.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/users.py
@@ -14,7 +14,7 @@ from pydantic import BaseModel, field_validator
from sqlalchemy import delete, func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
-from meshbay_hub import mail
+from meshbay_hub import hub_settings, mail
from meshbay_hub.api.deps import get_current_user, require_user_scope
from meshbay_hub.api.middleware import limiter
from meshbay_hub.api.netutil import client_ip
@@ -130,10 +130,6 @@ class RefreshRequest(BaseModel):
# ── Endpoints ─────────────────────────────────────────────────────────────────
-# Between two verification mails to one pending account.
-VERIFICATION_RESEND_COOLDOWN = 120
-
-
@router.post("/register", status_code=201)
@limiter.limit("5/minute")
async def register(
@@ -159,13 +155,16 @@ async def register(
# `mail.py` bounds the recipient regardless, and this stops the
# door being hammered — the answer is the same either way, which
# is the one this endpoint has always given.
+ resend_cooldown = await hub_settings.get_int(
+ db, "mail.verification_resend_cooldown",
+ hub_settings.mail_default("verification_resend_cooldown"))
recent = await db.execute(
select(EmailVerification).where(
EmailVerification.user_id == found.id,
EmailVerification.purpose == "registration",
EmailVerification.created_at
> datetime.now(timezone.utc)
- - timedelta(seconds=VERIFICATION_RESEND_COOLDOWN),
+ - timedelta(seconds=resend_cooldown),
))
if not recent.first():
await _create_and_send_verification(
@@ -245,7 +244,8 @@ async def _create_and_send_verification(
))
await db.flush()
await mail.send_off_loop(
- mail.send_verification_code, email, code, recovery_key=recovery_key)
+ db, mail.send_verification_code, email, code,
+ purpose="registration", recovery_key=recovery_key)
class VerifyEmailRequest(BaseModel):
@@ -657,13 +657,6 @@ class UpdateProfileRequest(BaseModel):
return v
-# One change-of-address mail per account per this many seconds. The window is
-# the account's, not the caller's IP: the point is the mailbox on the receiving
-# end, and an IP is not what fills it.
-EMAIL_CHANGE_COOLDOWN = 60
-MAX_EMAIL_CHANGES_PER_DAY = 3
-
-
@router.patch("/me")
@limiter.limit("10/minute")
async def update_profile(
@@ -688,40 +681,40 @@ async def update_profile(
new_email = body.email.strip()
eh = hash_email_blind(new_email)
- # A per-account floor under the rate limit above, which counts by IP
- # and so is not a bound on how much mail one account can cause.
- recent = await db.execute(
+ # How often one account may point the hub at a *different* address.
+ # Long, because this is the only path where a signed-in account chooses
+ # who receives a message, and a short delay alone still allows one
+ # stranger per interval indefinitely.
+ #
+ # Asking again for the address already pending is exempt: it reaches no
+ # new recipient — and that recipient is bounded by the per-destination
+ # allowance anyway — while without the exemption a typo would lock the
+ # account out of correcting it for the whole window.
+ pending = (await db.execute(
select(EmailVerification).where(
EmailVerification.user_id == current_user.id,
EmailVerification.purpose == "email_change",
- EmailVerification.created_at
- > datetime.now(timezone.utc) - timedelta(seconds=EMAIL_CHANGE_COOLDOWN),
- ))
- if recent.first():
- raise HTTPException(
- status_code=429,
- detail="A code was just sent. Wait a minute before asking again.")
+ EmailVerification.verified_at.is_(None),
+ ).order_by(EmailVerification.created_at.desc()))).scalars().first()
+ same_address_again = pending is not None and pending.email_hash == eh
- # And a ceiling per day, because the cooldown alone still allows one
- # stranger a minute — 1440 a day, from one account, on a hub where
- # registration is open. This is the only path where a signed-in
- # account chooses the recipient, so it is the only one that needs it.
- #
- # Counted from the IP log, not from EmailVerification: the block below
- # deletes this account's unverified rows before writing a new one, so
- # counting those would have counted one, always. (Which is what the
- # first version of this did.) The log entry is worth having for its
- # own sake — this endpoint wrote none, alone among the ones that mail.
- today = await db.execute(
- select(func.count()).select_from(IPLog).where(
- IPLog.user_id == current_user.id,
- IPLog.event == "email_change_request",
- IPLog.timestamp > datetime.now(timezone.utc) - timedelta(days=1),
- ))
- if (today.scalar() or 0) >= MAX_EMAIL_CHANGES_PER_DAY:
- raise HTTPException(
- status_code=429,
- detail="Too many address changes today. Try again tomorrow.")
+ if not same_address_again:
+ cooldown = await hub_settings.get_int(
+ db, "mail.email_change_cooldown",
+ hub_settings.mail_default("email_change_cooldown"))
+ since = datetime.now(timezone.utc) - timedelta(seconds=cooldown)
+ recent = await db.execute(
+ select(IPLog).where(
+ IPLog.user_id == current_user.id,
+ IPLog.event == "email_change_request",
+ IPLog.timestamp > since,
+ ))
+ if recent.first():
+ raise HTTPException(
+ status_code=429,
+ detail="This account changed its address recently. "
+ "Try again later, or ask for a new code for the "
+ "address already pending.")
# Check that no other active/pending account uses this email
dup = await db.execute(
@@ -755,7 +748,8 @@ async def update_profile(
db.add(IPLog(user_id=current_user.id, event="email_change_request",
ip_address=client_ip(request)))
await db.flush()
- await mail.send_off_loop(mail.send_email_change_code, new_email, code)
+ await mail.send_off_loop(db, mail.send_email_change_code, new_email, code,
+ purpose="email_change")
pending_email = new_email
await db.commit()
@@ -930,12 +924,6 @@ class ResetPasswordRequest(BaseModel):
new_auth_key: str
-# One reset mail per account per this many seconds, whoever asks and from
-# wherever. The rate limit above counts by IP, which bounds a caller, not an
-# inbox.
-RESET_MAIL_COOLDOWN = 60
-
-
@router.post("/password/reset-request")
@limiter.limit("5/minute")
async def password_reset_request(
@@ -963,12 +951,15 @@ async def password_reset_request(
# Per account, under the per-IP limit above. Knowing the pair is the
# hard part and this endpoint is careful about it, but once someone
# does, the cost of repeating lands in a mailbox that is not theirs.
+ reset_cooldown = await hub_settings.get_int(
+ db, "mail.reset_cooldown",
+ hub_settings.mail_default("reset_cooldown"))
recent = await db.execute(
select(EmailVerification).where(
EmailVerification.user_id == user.id,
EmailVerification.purpose == "password_reset",
EmailVerification.created_at
- > datetime.now(timezone.utc) - timedelta(seconds=RESET_MAIL_COOLDOWN),
+ > datetime.now(timezone.utc) - timedelta(seconds=reset_cooldown),
))
if recent.first():
return {"status": "sent_if_exists"}
@@ -996,7 +987,8 @@ async def password_reset_request(
await db.flush()
try:
await mail.send_off_loop(
- mail.send_password_reset_code, decrypt_email(user.email), code)
+ db, mail.send_password_reset_code, decrypt_email(user.email), code,
+ purpose="password_reset")
except Exception:
log.exception("Failed to send passphrase reset code")
await db.commit()