From ea06309a6ec04baa803be489d4fdb462e088958c Mon Sep 17 00:00:00 2001 From: Gros Frumos Date: Sat, 21 Mar 2026 12:42:13 +0200 Subject: [PATCH] kin: BATON-BIZ-001-backend_dev --- backend/config.py | 2 ++ backend/db.py | 24 +++++++++++++ backend/main.py | 43 ++++++++++++++++++++++- backend/middleware.py | 82 +++++++++++++++++++++++++++++++++++++++++++ backend/models.py | 10 ++++++ 5 files changed, 160 insertions(+), 1 deletion(-) diff --git a/backend/config.py b/backend/config.py index 7535cc0..305d05e 100644 --- a/backend/config.py +++ b/backend/config.py @@ -26,3 +26,5 @@ ADMIN_CHAT_ID: str = _require("ADMIN_CHAT_ID") VAPID_PRIVATE_KEY: str = os.getenv("VAPID_PRIVATE_KEY", "") VAPID_PUBLIC_KEY: str = os.getenv("VAPID_PUBLIC_KEY", "") VAPID_CLAIMS_EMAIL: str = os.getenv("VAPID_CLAIMS_EMAIL", "") +JWT_SECRET: str = os.getenv("JWT_SECRET", "") +JWT_TOKEN_EXPIRE_SECONDS: int = int(os.getenv("JWT_TOKEN_EXPIRE_SECONDS", "2592000")) # 30 days diff --git a/backend/db.py b/backend/db.py index d733006..5e2541c 100644 --- a/backend/db.py +++ b/backend/db.py @@ -352,6 +352,30 @@ async def update_registration_status(reg_id: int, status: str) -> bool: return changed +async def get_registration_by_login_or_email(login_or_email: str) -> Optional[dict]: + async with _get_conn() as conn: + async with conn.execute( + """ + SELECT id, email, login, password_hash, status, push_subscription, created_at + FROM registrations + WHERE login = ? OR email = ? + """, + (login_or_email, login_or_email), + ) as cur: + row = await cur.fetchone() + if row is None: + return None + return { + "id": row["id"], + "email": row["email"], + "login": row["login"], + "password_hash": row["password_hash"], + "status": row["status"], + "push_subscription": row["push_subscription"], + "created_at": row["created_at"], + } + + async def save_telegram_batch( message_text: str, signals_count: int, diff --git a/backend/main.py b/backend/main.py index ce6f4ea..d064b40 100644 --- a/backend/main.py +++ b/backend/main.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio import hashlib +import hmac import logging import os import secrets @@ -16,11 +17,21 @@ from fastapi.responses import JSONResponse from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from backend import config, db, push, telegram -from backend.middleware import rate_limit_auth_register, rate_limit_register, rate_limit_signal, verify_admin_token, verify_webhook_secret +from backend.middleware import ( + create_auth_token, + rate_limit_auth_login, + rate_limit_auth_register, + rate_limit_register, + rate_limit_signal, + verify_admin_token, + verify_webhook_secret, +) from backend.models import ( AdminBlockRequest, AdminCreateUserRequest, AdminSetPasswordRequest, + AuthLoginRequest, + AuthLoginResponse, AuthRegisterRequest, AuthRegisterResponse, RegisterRequest, @@ -51,6 +62,18 @@ def _hash_password(password: str) -> str: dk = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, 260_000) return f"{salt.hex()}:{dk.hex()}" +def _verify_password(password: str, stored_hash: str) -> bool: + """Verify a password against a stored PBKDF2-HMAC-SHA256 hash (salt_hex:dk_hex).""" + try: + salt_hex, dk_hex = stored_hash.split(":", 1) + salt = bytes.fromhex(salt_hex) + expected_dk = bytes.fromhex(dk_hex) + actual_dk = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, 260_000) + return hmac.compare_digest(actual_dk, expected_dk) + except Exception: + return False + + # aggregator = telegram.SignalAggregator(interval=10) # v2.0 feature — отключено в v1 (ADR-004) _KEEPALIVE_INTERVAL = 600 # 10 минут @@ -225,6 +248,24 @@ async def auth_register( return AuthRegisterResponse(status="pending", message="Заявка отправлена на рассмотрение") +@app.post("/api/auth/login", response_model=AuthLoginResponse) +async def auth_login( + body: AuthLoginRequest, + _: None = Depends(rate_limit_auth_login), +) -> 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"]): + raise HTTPException(status_code=401, detail="Неверный логин или пароль") + if reg["status"] == "pending": + raise HTTPException(status_code=403, detail="Ваша заявка ожидает рассмотрения") + if reg["status"] == "rejected": + raise HTTPException(status_code=403, detail="Ваша заявка отклонена") + if reg["status"] != "approved": + raise HTTPException(status_code=403, detail="Доступ запрещён") + token = create_auth_token(reg["id"], reg["login"]) + return AuthLoginResponse(token=token, login=reg["login"]) + + async def _handle_callback_query(cb: dict) -> None: """Process approve/reject callback from admin Telegram inline buttons.""" data = cb.get("data", "") diff --git a/backend/middleware.py b/backend/middleware.py index 1d183a9..27f1fd3 100644 --- a/backend/middleware.py +++ b/backend/middleware.py @@ -1,6 +1,11 @@ from __future__ import annotations +import base64 +import hashlib +import hmac +import json import secrets +import time from typing import Optional from fastapi import Depends, Header, HTTPException, Request @@ -8,6 +13,12 @@ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from backend import config, db +# JWT secret: stable across restarts if JWT_SECRET env var is set; random per-process otherwise +_JWT_SECRET: str = config.JWT_SECRET or secrets.token_hex(32) +_JWT_HEADER_B64: str = ( + base64.urlsafe_b64encode(b'{"alg":"HS256","typ":"JWT"}').rstrip(b"=").decode() +) + _bearer = HTTPBearer(auto_error=False) _RATE_LIMIT = 5 @@ -65,3 +76,74 @@ async def rate_limit_auth_register(request: Request) -> None: count = await db.rate_limit_increment(key, _AUTH_REGISTER_RATE_WINDOW) if count > _AUTH_REGISTER_RATE_LIMIT: raise HTTPException(status_code=429, detail="Too Many Requests") + + +_AUTH_LOGIN_RATE_LIMIT = 5 +_AUTH_LOGIN_RATE_WINDOW = 900 # 15 minutes + + +def _b64url_encode(data: bytes) -> str: + return base64.urlsafe_b64encode(data).rstrip(b"=").decode() + + +def _b64url_decode(s: str) -> bytes: + padding = 4 - len(s) % 4 + if padding != 4: + s += "=" * padding + return base64.urlsafe_b64decode(s) + + +def create_auth_token(reg_id: int, login: str) -> str: + """Create a signed HS256 JWT for an approved registration.""" + now = int(time.time()) + payload = { + "sub": str(reg_id), + "login": login, + "iat": now, + "exp": now + config.JWT_TOKEN_EXPIRE_SECONDS, + } + payload_b64 = _b64url_encode(json.dumps(payload, separators=(",", ":")).encode()) + signing_input = f"{_JWT_HEADER_B64}.{payload_b64}" + sig = hmac.new( + _JWT_SECRET.encode(), signing_input.encode(), hashlib.sha256 + ).digest() + return f"{signing_input}.{_b64url_encode(sig)}" + + +def _verify_jwt_token(token: str) -> dict: + """Verify token signature and expiry. Returns payload dict on success.""" + parts = token.split(".") + if len(parts) != 3: + raise ValueError("Invalid token format") + header_b64, payload_b64, sig_b64 = parts + signing_input = f"{header_b64}.{payload_b64}" + expected_sig = hmac.new( + _JWT_SECRET.encode(), signing_input.encode(), hashlib.sha256 + ).digest() + actual_sig = _b64url_decode(sig_b64) + if not hmac.compare_digest(expected_sig, actual_sig): + raise ValueError("Invalid signature") + payload = json.loads(_b64url_decode(payload_b64)) + if payload.get("exp", 0) < time.time(): + raise ValueError("Token expired") + return payload + + +async def rate_limit_auth_login(request: Request) -> None: + key = f"login:{_get_client_ip(request)}" + count = await db.rate_limit_increment(key, _AUTH_LOGIN_RATE_WINDOW) + if count > _AUTH_LOGIN_RATE_LIMIT: + raise HTTPException(status_code=429, detail="Too Many Requests") + + +async def verify_auth_token( + credentials: Optional[HTTPAuthorizationCredentials] = Depends(_bearer), +) -> dict: + """Dependency for protected endpoints — verifies Bearer JWT, returns payload.""" + if credentials is None: + raise HTTPException(status_code=401, detail="Unauthorized") + try: + payload = _verify_jwt_token(credentials.credentials) + except Exception: + raise HTTPException(status_code=401, detail="Unauthorized") + return payload diff --git a/backend/models.py b/backend/models.py index 065d0c8..b3d847a 100644 --- a/backend/models.py +++ b/backend/models.py @@ -66,3 +66,13 @@ class AuthRegisterRequest(BaseModel): class AuthRegisterResponse(BaseModel): status: str message: str + + +class AuthLoginRequest(BaseModel): + login_or_email: str = Field(..., min_length=1, max_length=255) + password: str = Field(..., min_length=1, max_length=128) + + +class AuthLoginResponse(BaseModel): + token: str + login: str