kin: BATON-BIZ-001-backend_dev
This commit is contained in:
parent
e266b6506e
commit
ea06309a6e
5 changed files with 160 additions and 1 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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", "")
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue