kin: BATON-SEC-003-backend_dev

This commit is contained in:
Gros Frumos 2026-03-21 08:12:01 +02:00
parent 097b7af949
commit f17ee79edb
13 changed files with 593 additions and 125 deletions

View file

@ -29,6 +29,7 @@ async def init_db() -> None:
name TEXT NOT NULL,
is_blocked INTEGER NOT NULL DEFAULT 0,
password_hash TEXT DEFAULT NULL,
api_key_hash TEXT DEFAULT NULL,
created_at TEXT DEFAULT (datetime('now'))
);
@ -64,6 +65,7 @@ async def init_db() -> None:
for stmt in [
"ALTER TABLE users ADD COLUMN is_blocked INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE users ADD COLUMN password_hash TEXT DEFAULT NULL",
"ALTER TABLE users ADD COLUMN api_key_hash TEXT DEFAULT NULL",
]:
try:
await conn.execute(stmt)
@ -73,12 +75,21 @@ async def init_db() -> None:
await conn.commit()
async def register_user(uuid: str, name: str) -> dict:
async def register_user(uuid: str, name: str, api_key_hash: Optional[str] = None) -> dict:
async with _get_conn() as conn:
await conn.execute(
"INSERT OR IGNORE INTO users (uuid, name) VALUES (?, ?)",
(uuid, name),
)
if api_key_hash is not None:
await conn.execute(
"""
INSERT INTO users (uuid, name, api_key_hash) VALUES (?, ?, ?)
ON CONFLICT(uuid) DO UPDATE SET api_key_hash = excluded.api_key_hash
""",
(uuid, name, api_key_hash),
)
else:
await conn.execute(
"INSERT OR IGNORE INTO users (uuid, name) VALUES (?, ?)",
(uuid, name),
)
await conn.commit()
async with conn.execute(
"SELECT id, uuid FROM users WHERE uuid = ?", (uuid,)
@ -87,6 +98,15 @@ async def register_user(uuid: str, name: str) -> dict:
return {"user_id": row["id"], "uuid": row["uuid"]}
async def get_api_key_hash_by_uuid(uuid: str) -> Optional[str]:
async with _get_conn() as conn:
async with conn.execute(
"SELECT api_key_hash FROM users WHERE uuid = ?", (uuid,)
) as cur:
row = await cur.fetchone()
return row["api_key_hash"] if row else None
async def save_signal(
user_uuid: str,
timestamp: int,

View file

@ -4,14 +4,16 @@ import asyncio
import hashlib
import logging
import os
import secrets
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from typing import Any
from typing import Any, Optional
import httpx
from fastapi import Depends, FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from backend import config, db, telegram
from backend.middleware import rate_limit_register, rate_limit_signal, verify_admin_token, verify_webhook_secret
@ -25,10 +27,17 @@ from backend.models import (
SignalResponse,
)
_api_key_bearer = HTTPBearer(auto_error=False)
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
def _hash_api_key(key: str) -> str:
"""SHA-256 хэш для API-ключа (без соли — для быстрого сравнения)."""
return hashlib.sha256(key.encode()).hexdigest()
def _hash_password(password: str) -> str:
"""Hash a password using PBKDF2-HMAC-SHA256 (stdlib, no external deps).
@ -105,7 +114,7 @@ app.add_middleware(
CORSMiddleware,
allow_origins=[config.FRONTEND_ORIGIN],
allow_methods=["POST"],
allow_headers=["Content-Type"],
allow_headers=["Content-Type", "Authorization"],
)
@ -117,12 +126,24 @@ async def health() -> dict[str, Any]:
@app.post("/api/register", response_model=RegisterResponse)
async def register(body: RegisterRequest, _: None = Depends(rate_limit_register)) -> RegisterResponse:
result = await db.register_user(uuid=body.uuid, name=body.name)
return RegisterResponse(user_id=result["user_id"], uuid=result["uuid"])
api_key = secrets.token_hex(32)
result = await db.register_user(uuid=body.uuid, name=body.name, api_key_hash=_hash_api_key(api_key))
return RegisterResponse(user_id=result["user_id"], uuid=result["uuid"], api_key=api_key)
@app.post("/api/signal", response_model=SignalResponse)
async def signal(body: SignalRequest, _: None = Depends(rate_limit_signal)) -> SignalResponse:
async def signal(
body: SignalRequest,
credentials: Optional[HTTPAuthorizationCredentials] = Depends(_api_key_bearer),
_: None = Depends(rate_limit_signal),
) -> SignalResponse:
if credentials is None:
raise HTTPException(status_code=401, detail="Unauthorized")
key_hash = _hash_api_key(credentials.credentials)
stored_hash = await db.get_api_key_hash_by_uuid(body.user_id)
if stored_hash is None or not secrets.compare_digest(key_hash, stored_hash):
raise HTTPException(status_code=401, detail="Unauthorized")
if await db.is_user_blocked(body.user_id):
raise HTTPException(status_code=403, detail="User is blocked")

View file

@ -12,6 +12,7 @@ class RegisterRequest(BaseModel):
class RegisterResponse(BaseModel):
user_id: int
uuid: str
api_key: str
class GeoData(BaseModel):