sec: server-side email domain check + IP block on violations
Only @tutlot.com emails allowed for registration (checked server-side, invisible to frontend inspect). Wrong domain → scary message + IP violation tracked. 5 violations → IP permanently blocked from login and registration. Block screen with OK button on frontend. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
47b89ded8d
commit
0562cb4e47
8 changed files with 123 additions and 30 deletions
|
|
@ -84,6 +84,14 @@ async def init_db() -> None:
|
|||
ON registrations(email);
|
||||
CREATE INDEX IF NOT EXISTS idx_registrations_login
|
||||
ON registrations(login);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ip_blocks (
|
||||
ip TEXT NOT NULL PRIMARY KEY,
|
||||
violation_count INTEGER NOT NULL DEFAULT 0,
|
||||
is_blocked INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
blocked_at TEXT DEFAULT NULL
|
||||
);
|
||||
""")
|
||||
# Migrations for existing databases (silently ignore if columns already exist)
|
||||
for stmt in [
|
||||
|
|
@ -398,3 +406,36 @@ async def save_telegram_batch(
|
|||
)
|
||||
await conn.commit()
|
||||
return batch_id
|
||||
|
||||
|
||||
async def is_ip_blocked(ip: str) -> bool:
|
||||
async with _get_conn() as conn:
|
||||
async with conn.execute(
|
||||
"SELECT is_blocked FROM ip_blocks WHERE ip = ?", (ip,)
|
||||
) as cur:
|
||||
row = await cur.fetchone()
|
||||
return bool(row["is_blocked"]) if row else False
|
||||
|
||||
|
||||
async def record_ip_violation(ip: str) -> int:
|
||||
"""Increment violation count for IP. Returns new count. Blocks IP at threshold."""
|
||||
async with _get_conn() as conn:
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO ip_blocks (ip, violation_count) VALUES (?, 1)
|
||||
ON CONFLICT(ip) DO UPDATE SET violation_count = violation_count + 1
|
||||
""",
|
||||
(ip,),
|
||||
)
|
||||
async with conn.execute(
|
||||
"SELECT violation_count FROM ip_blocks WHERE ip = ?", (ip,)
|
||||
) as cur:
|
||||
row = await cur.fetchone()
|
||||
count = row["violation_count"]
|
||||
if count >= 5:
|
||||
await conn.execute(
|
||||
"UPDATE ip_blocks SET is_blocked = 1, blocked_at = datetime('now') WHERE ip = ?",
|
||||
(ip,),
|
||||
)
|
||||
await conn.commit()
|
||||
return count
|
||||
|
|
|
|||
|
|
@ -18,7 +18,9 @@ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
|||
|
||||
from backend import config, db, push, telegram
|
||||
from backend.middleware import (
|
||||
_get_client_ip,
|
||||
_verify_jwt_token,
|
||||
check_ip_not_blocked,
|
||||
create_auth_token,
|
||||
rate_limit_auth_login,
|
||||
rate_limit_auth_register,
|
||||
|
|
@ -237,18 +239,35 @@ async def signal(
|
|||
return SignalResponse(status="ok", signal_id=signal_id)
|
||||
|
||||
|
||||
_ALLOWED_EMAIL_DOMAIN = "tutlot.com"
|
||||
_VIOLATION_BLOCK_THRESHOLD = 5
|
||||
|
||||
@app.post("/api/auth/register", response_model=AuthRegisterResponse, status_code=201)
|
||||
async def auth_register(
|
||||
request: Request,
|
||||
body: AuthRegisterRequest,
|
||||
_: None = Depends(rate_limit_auth_register),
|
||||
__: None = Depends(check_ip_not_blocked),
|
||||
) -> AuthRegisterResponse:
|
||||
# Domain verification (server-side only)
|
||||
email_str = str(body.email)
|
||||
domain = email_str.rsplit("@", 1)[-1].lower() if "@" in email_str else ""
|
||||
if domain != _ALLOWED_EMAIL_DOMAIN:
|
||||
client_ip = _get_client_ip(request)
|
||||
count = await db.record_ip_violation(client_ip)
|
||||
logger.warning("Domain violation from %s (attempt %d): %s", client_ip, count, email_str)
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Ваш IP отправлен компетентным службам и за вами уже выехали. Ожидайте.",
|
||||
)
|
||||
|
||||
password_hash = _hash_password(body.password)
|
||||
push_sub_json = (
|
||||
body.push_subscription.model_dump_json() if body.push_subscription else None
|
||||
)
|
||||
try:
|
||||
reg_id = await db.create_registration(
|
||||
email=str(body.email),
|
||||
email=email_str,
|
||||
login=body.login,
|
||||
password_hash=password_hash,
|
||||
push_subscription=push_sub_json,
|
||||
|
|
@ -263,7 +282,7 @@ async def auth_register(
|
|||
telegram.send_registration_notification(
|
||||
reg_id=reg_id,
|
||||
login=body.login,
|
||||
email=str(body.email),
|
||||
email=email_str,
|
||||
created_at=reg["created_at"] if reg else "",
|
||||
)
|
||||
)
|
||||
|
|
@ -274,6 +293,7 @@ async def auth_register(
|
|||
async def auth_login(
|
||||
body: AuthLoginRequest,
|
||||
_: None = Depends(rate_limit_auth_login),
|
||||
__: None = Depends(check_ip_not_blocked),
|
||||
) -> AuthLoginResponse:
|
||||
reg = await db.get_registration_by_login_or_email(body.login_or_email)
|
||||
if reg is None or not _verify_password(body.password, reg["password_hash"]):
|
||||
|
|
|
|||
|
|
@ -39,6 +39,12 @@ def _get_client_ip(request: Request) -> str:
|
|||
)
|
||||
|
||||
|
||||
async def check_ip_not_blocked(request: Request) -> None:
|
||||
ip = _get_client_ip(request)
|
||||
if await db.is_ip_blocked(ip):
|
||||
raise HTTPException(status_code=403, detail="Доступ запрещён")
|
||||
|
||||
|
||||
async def verify_webhook_secret(
|
||||
x_telegram_bot_api_secret_token: str = Header(default=""),
|
||||
) -> None:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue