diff --git a/backend/db.py b/backend/db.py
index 5e2541c..6243e04 100644
--- a/backend/db.py
+++ b/backend/db.py
@@ -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
diff --git a/backend/main.py b/backend/main.py
index 12105c3..ff23898 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -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"]):
diff --git a/backend/middleware.py b/backend/middleware.py
index 27f1fd3..55f4269 100644
--- a/backend/middleware.py
+++ b/backend/middleware.py
@@ -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:
diff --git a/frontend/app.js b/frontend/app.js
index 309d784..0563b77 100644
--- a/frontend/app.js
+++ b/frontend/app.js
@@ -423,12 +423,29 @@ async function _handleSignUp() {
} catch (_) {}
}
}
- _setRegStatus(msg, 'error');
- btn.disabled = false;
- btn.textContent = originalText;
+ if (err && err.status === 403 && msg !== 'Ошибка. Попробуйте ещё раз.') {
+ _showBlockScreen(msg);
+ } else {
+ _setRegStatus(msg, 'error');
+ btn.disabled = false;
+ btn.textContent = originalText;
+ }
}
}
+function _showBlockScreen(msg) {
+ const screen = document.getElementById('screen-onboarding');
+ if (!screen) return;
+ screen.innerHTML =
+ '
' +
+ '
' + msg + '
' +
+ '
' +
+ '
';
+ document.getElementById('btn-block-ok').addEventListener('click', () => {
+ location.reload();
+ });
+}
+
// ========== Init ==========
function _init() {
diff --git a/frontend/style.css b/frontend/style.css
index 36f7685..dc3f3e6 100644
--- a/frontend/style.css
+++ b/frontend/style.css
@@ -230,3 +230,12 @@ body {
.reg-status[hidden] { display: none; }
.reg-status--error { color: #f87171; }
.reg-status--success { color: #4ade80; }
+
+.block-message {
+ color: #f87171;
+ font-size: 16px;
+ text-align: center;
+ line-height: 1.6;
+ padding: 20px;
+ max-width: 320px;
+}
diff --git a/tests/test_baton_008.py b/tests/test_baton_008.py
index b1e0c03..cc597a6 100644
--- a/tests/test_baton_008.py
+++ b/tests/test_baton_008.py
@@ -33,7 +33,7 @@ _WEBHOOK_SECRET = "test-webhook-secret"
_WEBHOOK_HEADERS = {"X-Telegram-Bot-Api-Secret-Token": _WEBHOOK_SECRET}
_VALID_PAYLOAD = {
- "email": "user@example.com",
+ "email": "user@tutlot.com",
"login": "testuser",
"password": "strongpassword123",
}
@@ -68,7 +68,7 @@ async def test_auth_register_fire_and_forget_telegram_error_still_returns_201():
):
resp = await client.post(
"/api/auth/register",
- json={**_VALID_PAYLOAD, "email": "other@example.com", "login": "otheruser"},
+ json={**_VALID_PAYLOAD, "email": "other@tutlot.com", "login": "otheruser"},
)
await asyncio.sleep(0)
@@ -106,7 +106,7 @@ async def test_auth_register_409_on_duplicate_login():
r2 = await client.post(
"/api/auth/register",
- json={**_VALID_PAYLOAD, "email": "different@example.com"},
+ json={**_VALID_PAYLOAD, "email": "different@tutlot.com"},
)
assert r2.status_code == 409, f"Expected 409 on duplicate login, got {r2.status_code}"
@@ -365,7 +365,7 @@ async def test_register_without_push_subscription():
with patch("backend.telegram.send_registration_notification", new_callable=AsyncMock):
resp = await client.post(
"/api/auth/register",
- json={**_VALID_PAYLOAD, "email": "nopush@example.com", "login": "nopushuser"},
+ json={**_VALID_PAYLOAD, "email": "nopush@tutlot.com", "login": "nopushuser"},
)
assert resp.status_code == 201
assert resp.json()["status"] == "pending"
@@ -424,7 +424,7 @@ async def test_webhook_callback_approve_edits_message():
with patch("backend.telegram.send_registration_notification", new_callable=AsyncMock):
reg_resp = await client.post(
"/api/auth/register",
- json={**_VALID_PAYLOAD, "email": "edit@example.com", "login": "edituser"},
+ json={**_VALID_PAYLOAD, "email": "edit@tutlot.com", "login": "edituser"},
)
assert reg_resp.status_code == 201
@@ -469,7 +469,7 @@ async def test_webhook_callback_answer_sent():
with patch("backend.telegram.send_registration_notification", new_callable=AsyncMock):
reg_resp = await client.post(
"/api/auth/register",
- json={**_VALID_PAYLOAD, "email": "answer@example.com", "login": "answeruser"},
+ json={**_VALID_PAYLOAD, "email": "answer@tutlot.com", "login": "answeruser"},
)
assert reg_resp.status_code == 201
@@ -562,7 +562,7 @@ async def test_password_hash_stored_in_pbkdf2_format():
with patch("backend.telegram.send_registration_notification", new_callable=AsyncMock):
await client.post(
"/api/auth/register",
- json={**_VALID_PAYLOAD, "email": "pbkdf2@example.com", "login": "pbkdf2user"},
+ json={**_VALID_PAYLOAD, "email": "pbkdf2@tutlot.com", "login": "pbkdf2user"},
)
async with aiosqlite.connect(_cfg.DB_PATH) as conn:
@@ -601,7 +601,7 @@ async def test_webhook_callback_double_approve_does_not_send_push():
with patch("backend.telegram.send_registration_notification", new_callable=AsyncMock):
reg_resp = await client.post(
"/api/auth/register",
- json={**_VALID_PAYLOAD, "email": "double@example.com", "login": "doubleuser", "push_subscription": push_sub},
+ json={**_VALID_PAYLOAD, "email": "double@tutlot.com", "login": "doubleuser", "push_subscription": push_sub},
)
assert reg_resp.status_code == 201
@@ -653,7 +653,7 @@ async def test_webhook_callback_double_approve_status_stays_approved():
with patch("backend.telegram.send_registration_notification", new_callable=AsyncMock):
reg_resp = await client.post(
"/api/auth/register",
- json={**_VALID_PAYLOAD, "email": "stay@example.com", "login": "stayuser"},
+ json={**_VALID_PAYLOAD, "email": "stay@tutlot.com", "login": "stayuser"},
)
assert reg_resp.status_code == 201
@@ -698,7 +698,7 @@ async def test_webhook_callback_approve_after_reject_status_stays_rejected():
with patch("backend.telegram.send_registration_notification", new_callable=AsyncMock):
reg_resp = await client.post(
"/api/auth/register",
- json={**_VALID_PAYLOAD, "email": "artest@example.com", "login": "artestuser"},
+ json={**_VALID_PAYLOAD, "email": "artest@tutlot.com", "login": "artestuser"},
)
assert reg_resp.status_code == 201
@@ -759,7 +759,7 @@ async def test_auth_register_rate_limit_fourth_request_returns_429():
r = await client.post(
"/api/auth/register",
json={
- "email": f"ratetest{i}@example.com",
+ "email": f"ratetest{i}@tutlot.com",
"login": f"ratetest{i}",
"password": "strongpassword123",
},
@@ -770,7 +770,7 @@ async def test_auth_register_rate_limit_fourth_request_returns_429():
r4 = await client.post(
"/api/auth/register",
json={
- "email": "ratetest4@example.com",
+ "email": "ratetest4@tutlot.com",
"login": "ratetest4",
"password": "strongpassword123",
},
diff --git a/tests/test_baton_008_frontend.py b/tests/test_baton_008_frontend.py
index c19bd96..5b8eeb2 100644
--- a/tests/test_baton_008_frontend.py
+++ b/tests/test_baton_008_frontend.py
@@ -30,7 +30,7 @@ APP_JS = PROJECT_ROOT / "frontend" / "app.js"
from tests.conftest import make_app_client
_VALID_PAYLOAD = {
- "email": "frontend_test@example.com",
+ "email": "frontend_test@tutlot.com",
"login": "frontenduser",
"password": "strongpassword123",
}
@@ -417,7 +417,7 @@ async def test_register_duplicate_login_returns_409():
r2 = await client.post(
"/api/auth/register",
- json={**_VALID_PAYLOAD, "email": "another@example.com"},
+ json={**_VALID_PAYLOAD, "email": "another@tutlot.com"},
)
assert r2.status_code == 409, (
diff --git a/tests/test_biz_001.py b/tests/test_biz_001.py
index 21f822e..b48d176 100644
--- a/tests/test_biz_001.py
+++ b/tests/test_biz_001.py
@@ -67,7 +67,7 @@ async def _reject(reg_id: int) -> None:
async def test_login_by_login_field_returns_200_with_token():
"""Approved user can login using their login field → 200 + token."""
async with make_app_client() as client:
- reg_id = await _register_auth(client, "alice@example.com", "alice", "password123")
+ reg_id = await _register_auth(client, "alice@tutlot.com", "alice", "password123")
await _approve(reg_id)
resp = await client.post(
"/api/auth/login",
@@ -83,7 +83,7 @@ async def test_login_by_login_field_returns_200_with_token():
async def test_login_by_login_field_token_is_non_empty_string():
"""Token returned for approved login user is a non-empty string."""
async with make_app_client() as client:
- reg_id = await _register_auth(client, "alice2@example.com", "alice2", "password123")
+ reg_id = await _register_auth(client, "alice2@tutlot.com", "alice2", "password123")
await _approve(reg_id)
resp = await client.post(
"/api/auth/login",
@@ -102,11 +102,11 @@ async def test_login_by_login_field_token_is_non_empty_string():
async def test_login_by_email_field_returns_200_with_token():
"""Approved user can login using their email field → 200 + token."""
async with make_app_client() as client:
- reg_id = await _register_auth(client, "bob@example.com", "bobuser", "securepass1")
+ reg_id = await _register_auth(client, "bob@tutlot.com", "bobuser", "securepass1")
await _approve(reg_id)
resp = await client.post(
"/api/auth/login",
- json={"login_or_email": "bob@example.com", "password": "securepass1"},
+ json={"login_or_email": "bob@tutlot.com", "password": "securepass1"},
)
assert resp.status_code == 200
data = resp.json()
@@ -118,11 +118,11 @@ async def test_login_by_email_field_returns_200_with_token():
async def test_login_by_email_field_token_login_matches_registration():
"""Token response login field matches the login set during registration."""
async with make_app_client() as client:
- reg_id = await _register_auth(client, "bob2@example.com", "bob2user", "securepass1")
+ reg_id = await _register_auth(client, "bob2@tutlot.com", "bob2user", "securepass1")
await _approve(reg_id)
resp = await client.post(
"/api/auth/login",
- json={"login_or_email": "bob2@example.com", "password": "securepass1"},
+ json={"login_or_email": "bob2@tutlot.com", "password": "securepass1"},
)
assert resp.json()["login"] == "bob2user"
@@ -136,7 +136,7 @@ async def test_login_by_email_field_token_login_matches_registration():
async def test_wrong_password_returns_401():
"""Wrong password returns 401 with generic message (no detail about which field failed)."""
async with make_app_client() as client:
- reg_id = await _register_auth(client, "carol@example.com", "carol", "correctpass1")
+ reg_id = await _register_auth(client, "carol@tutlot.com", "carol", "correctpass1")
await _approve(reg_id)
resp = await client.post(
"/api/auth/login",
@@ -167,7 +167,7 @@ async def test_nonexistent_user_returns_401_same_message_as_wrong_password():
async def test_pending_user_login_returns_403():
"""User with pending status gets 403."""
async with make_app_client() as client:
- await _register_auth(client, "dave@example.com", "dave", "password123")
+ await _register_auth(client, "dave@tutlot.com", "dave", "password123")
# Status is 'pending' by default — no approval step
resp = await client.post(
"/api/auth/login",
@@ -180,7 +180,7 @@ async def test_pending_user_login_returns_403():
async def test_pending_user_login_403_message_is_human_readable():
"""403 message for pending user contains readable Russian text about the waiting status."""
async with make_app_client() as client:
- await _register_auth(client, "dave2@example.com", "dave2", "password123")
+ await _register_auth(client, "dave2@tutlot.com", "dave2", "password123")
resp = await client.post(
"/api/auth/login",
json={"login_or_email": "dave2", "password": "password123"},
@@ -197,7 +197,7 @@ async def test_pending_user_login_403_message_is_human_readable():
async def test_rejected_user_login_returns_403():
"""User with rejected status gets 403."""
async with make_app_client() as client:
- reg_id = await _register_auth(client, "eve@example.com", "evegirl", "password123")
+ reg_id = await _register_auth(client, "eve@tutlot.com", "evegirl", "password123")
await _reject(reg_id)
resp = await client.post(
"/api/auth/login",
@@ -210,7 +210,7 @@ async def test_rejected_user_login_returns_403():
async def test_rejected_user_login_403_message_is_human_readable():
"""403 message for rejected user contains readable Russian text about rejection."""
async with make_app_client() as client:
- reg_id = await _register_auth(client, "eve2@example.com", "eve2girl", "password123")
+ reg_id = await _register_auth(client, "eve2@tutlot.com", "eve2girl", "password123")
await _reject(reg_id)
resp = await client.post(
"/api/auth/login",