kin: BATON-BIZ-001-backend_dev

This commit is contained in:
Gros Frumos 2026-03-21 12:42:13 +02:00
parent e266b6506e
commit ea06309a6e
5 changed files with 160 additions and 1 deletions

View file

@ -26,3 +26,5 @@ ADMIN_CHAT_ID: str = _require("ADMIN_CHAT_ID")
VAPID_PRIVATE_KEY: str = os.getenv("VAPID_PRIVATE_KEY", "") VAPID_PRIVATE_KEY: str = os.getenv("VAPID_PRIVATE_KEY", "")
VAPID_PUBLIC_KEY: str = os.getenv("VAPID_PUBLIC_KEY", "") VAPID_PUBLIC_KEY: str = os.getenv("VAPID_PUBLIC_KEY", "")
VAPID_CLAIMS_EMAIL: str = os.getenv("VAPID_CLAIMS_EMAIL", "") 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

View file

@ -352,6 +352,30 @@ async def update_registration_status(reg_id: int, status: str) -> bool:
return changed 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( async def save_telegram_batch(
message_text: str, message_text: str,
signals_count: int, signals_count: int,

View file

@ -2,6 +2,7 @@ from __future__ import annotations
import asyncio import asyncio
import hashlib import hashlib
import hmac
import logging import logging
import os import os
import secrets import secrets
@ -16,11 +17,21 @@ from fastapi.responses import JSONResponse
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from backend import config, db, push, telegram 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 ( from backend.models import (
AdminBlockRequest, AdminBlockRequest,
AdminCreateUserRequest, AdminCreateUserRequest,
AdminSetPasswordRequest, AdminSetPasswordRequest,
AuthLoginRequest,
AuthLoginResponse,
AuthRegisterRequest, AuthRegisterRequest,
AuthRegisterResponse, AuthRegisterResponse,
RegisterRequest, RegisterRequest,
@ -51,6 +62,18 @@ def _hash_password(password: str) -> str:
dk = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, 260_000) dk = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, 260_000)
return f"{salt.hex()}:{dk.hex()}" 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) # aggregator = telegram.SignalAggregator(interval=10) # v2.0 feature — отключено в v1 (ADR-004)
_KEEPALIVE_INTERVAL = 600 # 10 минут _KEEPALIVE_INTERVAL = 600 # 10 минут
@ -225,6 +248,24 @@ async def auth_register(
return AuthRegisterResponse(status="pending", message="Заявка отправлена на рассмотрение") 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: async def _handle_callback_query(cb: dict) -> None:
"""Process approve/reject callback from admin Telegram inline buttons.""" """Process approve/reject callback from admin Telegram inline buttons."""
data = cb.get("data", "") data = cb.get("data", "")

View file

@ -1,6 +1,11 @@
from __future__ import annotations from __future__ import annotations
import base64
import hashlib
import hmac
import json
import secrets import secrets
import time
from typing import Optional from typing import Optional
from fastapi import Depends, Header, HTTPException, Request from fastapi import Depends, Header, HTTPException, Request
@ -8,6 +13,12 @@ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from backend import config, db 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) _bearer = HTTPBearer(auto_error=False)
_RATE_LIMIT = 5 _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) count = await db.rate_limit_increment(key, _AUTH_REGISTER_RATE_WINDOW)
if count > _AUTH_REGISTER_RATE_LIMIT: if count > _AUTH_REGISTER_RATE_LIMIT:
raise HTTPException(status_code=429, detail="Too Many Requests") 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

View file

@ -66,3 +66,13 @@ class AuthRegisterRequest(BaseModel):
class AuthRegisterResponse(BaseModel): class AuthRegisterResponse(BaseModel):
status: str status: str
message: 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