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",