From ec1c32a5bdecd448c522debff2315680b4a1d292 Mon Sep 17 00:00:00 2001 From: wowlikon Date: Sat, 24 Jan 2026 10:52:08 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A3=D0=BB=D1=83=D1=87=D1=88=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20=D0=B4=D0=BE=D0=BA=D1=83=D0=BC=D0=B5=D0=BD=D1=82?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D0=B8=20=D0=B8=20KDF=20=D1=81=20=D1=88=D0=B8?= =?UTF-8?q?=D1=84=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=D0=BC=20totp?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env | 2 +- README.md | 16 +- library_service/auth/__init__.py | 8 + library_service/auth/core.py | 72 +++++++- library_service/models/db/user.py | 2 +- library_service/routers/auth.py | 32 +++- library_service/routers/cap.py | 6 +- library_service/static/page/book.js | 2 +- library_service/templates/api.html | 271 +++++++++++++++++++++++++++- pyproject.toml | 2 +- uv.lock | 2 +- 11 files changed, 393 insertions(+), 22 deletions(-) diff --git a/.env b/.env index 824cbba..9b803b3 100644 --- a/.env +++ b/.env @@ -9,13 +9,13 @@ POSTGRES_DB="lib" # DEFAULT_ADMIN_USERNAME="admin" # DEFAULT_ADMIN_EMAIL="admin@example.com" # DEFAULT_ADMIN_PASSWORD="password-is-generated-randomly-on-first-launch" +SECRET_KEY="your-secret-key-change-in-production" # JWT ALGORITHM="HS256" REFRESH_TOKEN_EXPIRE_DAYS="7" ACCESS_TOKEN_EXPIRE_MINUTES="15" PARTIAL_TOKEN_EXPIRE_MINUTES="5" -SECRET_KEY="your-secret-key-change-in-production" # Hash ARGON2_TYPE="id" diff --git a/README.md b/README.md index d877b08..e8f53b5 100644 --- a/README.md +++ b/README.md @@ -144,10 +144,10 @@ #### **Пользователи** (`/api/users`) -| Метод | Эндпоинт | Доступ | Описание | -|--------|-------------------------------|----------------|------------------------------| -| POST | `/` | Админ | Создать нового пользователя | -| GET | `/` | Админ | Список всех пользователей | +| Метод | Эндпоинт | Доступ | Описание | +|--------|--------------------------------|----------------|------------------------------| +| POST | `/` | Админ | Создать нового пользователя | +| GET | `/` | Админ | Список всех пользователей | | GET | `/{id}` | Админ | Получить пользователя по ID | | PUT | `/{id}` | Админ | Обновить пользователя по ID | | DELETE | `/{id}` | Админ | Удалить пользователя по ID | @@ -156,6 +156,14 @@ | GET | `/roles` | Авторизованный | Список ролей в системе | +#### **CAPTCHA** (`/api/cap`) + +| Метод | Эндпоинт | Доступ | Описание | +|--------|---------------|-----------|-----------------| +| POST | `/challenge` | Публичный | Создание задачи | +| POST | `/redeem` | Публичный | Проверка задачи | + + #### **Прочее** (`/api`) | Метод | Эндпоинт | Доступ | Описание | diff --git a/library_service/auth/__init__.py b/library_service/auth/__init__.py index ee52174..89545b2 100644 --- a/library_service/auth/__init__.py +++ b/library_service/auth/__init__.py @@ -16,6 +16,10 @@ from .core import ( RECOVERY_CODE_SEGMENT_BYTES, RECOVERY_MIN_REMAINING_WARNING, RECOVERY_MAX_AGE_DAYS, + KeyDeriver, + deriver, + AES256Cipher, + cipher, verify_password, get_password_hash, create_access_token, @@ -75,6 +79,10 @@ __all__ = [ "RECOVERY_CODE_SEGMENT_BYTES", "RECOVERY_MIN_REMAINING_WARNING", "RECOVERY_MAX_AGE_DAYS", + "KeyDeriver", + "deriver", + "AES256Cipher", + "cipher", "verify_password", "get_password_hash", "create_access_token", diff --git a/library_service/auth/core.py b/library_service/auth/core.py index 982bdf7..7c44988 100644 --- a/library_service/auth/core.py +++ b/library_service/auth/core.py @@ -1,10 +1,13 @@ """Модуль основного функционала авторизации и аутентификации""" -import os from datetime import datetime, timedelta, timezone from typing import Annotated - from uuid import uuid4 +import hashlib +import os + +from argon2.low_level import hash_secret_raw, Type +from cryptography.hazmat.primitives.ciphers.aead import AESGCM from fastapi import Depends, HTTPException, status from fastapi.security import OAuth2PasswordBearer from jose import jwt, JWTError, ExpiredSignatureError @@ -17,7 +20,6 @@ from library_service.settings import get_session, get_logger # Конфигурация JWT из переменных окружения -SECRET_KEY = os.getenv("SECRET_KEY") ALGORITHM = os.getenv("ALGORITHM", "HS256") PARTIAL_TOKEN_EXPIRE_MINUTES = int(os.getenv("PARTIAL_TOKEN_EXPIRE_MINUTES", "5")) ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "15")) @@ -38,16 +40,76 @@ RECOVERY_CODE_SEGMENT_BYTES = int(os.getenv("RECOVERY_CODE_SEGMENT_BYTES", "2")) RECOVERY_MIN_REMAINING_WARNING = int(os.getenv("RECOVERY_MIN_REMAINING_WARNING", "3")) RECOVERY_MAX_AGE_DAYS = int(os.getenv("RECOVERY_MAX_AGE_DAYS", "365")) +SECRET_KEY = os.getenv("SECRET_KEY") + # Получение логгера logger = get_logger() # OAuth2 схема oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/token") + +class KeyDeriver: + def __init__(self, master_key: bytes): + self.master_key = master_key + + def derive( + self, + context: str, + key_len: int = 32, + time_cost: int = 12, + memory_cost: int = 512 * 1024, + parallelism: int = 4, + ) -> bytes: + """ + Формирование разных ключей из одного. + context: любая строка, например "aes", "hmac", "totp" + """ + salt = hashlib.sha256(context.encode("utf-8")).digest() + key = hash_secret_raw( + secret=self.master_key, + salt=salt, + time_cost=time_cost, + memory_cost=memory_cost, + parallelism=parallelism, + hash_len=key_len, + type=Type.ID, + ) + return key + + +class AES256Cipher: + def __init__(self, key: bytes): + if len(key) != 32: + raise ValueError("AES-256 требует ключ длиной 32 байта") + self.key = key + self.aesgcm = AESGCM(key) + + def encrypt(self, plaintext: bytes, nonce_len: int = 12) -> bytes: + """Зашифровывает данные с помощью AES256-GCM""" + nonce = os.urandom(nonce_len) + ct = self.aesgcm.encrypt(nonce, plaintext, associated_data=None) + return nonce + ct + + def decrypt(self, data: bytes, nonce_len: int = 12) -> bytes: + """Расшифровывает данные с помощью AES256-GCM""" + nonce = data[:nonce_len] + ct = data[nonce_len:] + return self.aesgcm.decrypt(nonce, ct, associated_data=None) + + # Проверка секретного ключа if not SECRET_KEY: raise RuntimeError("SECRET_KEY environment variable is required") +deriver = KeyDeriver(SECRET_KEY.encode()) + +jwt_key = deriver.derive("jwt", key_len=32) + +aes_key = deriver.derive("totp", key_len=32) +cipher = AES256Cipher(aes_key) + + # Хэширование паролей pwd_context = CryptContext( schemes=["argon2"], @@ -88,7 +150,7 @@ def _create_token( } if token_type == "refresh": to_encode.update({"jti": str(uuid4())}) - return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) + return jwt.encode(to_encode, jwt_key, algorithm=ALGORITHM) def create_partial_token(data: dict) -> str: @@ -119,7 +181,7 @@ def decode_token( headers={"WWW-Authenticate": "Bearer"}, ) try: - payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + payload = jwt.decode(token, jwt_key, algorithms=[ALGORITHM]) username: str | None = payload.get("sub") user_id: int | None = payload.get("user_id") token_type: str | None = payload.get("type") diff --git a/library_service/models/db/user.py b/library_service/models/db/user.py index 612a1e2..303e027 100644 --- a/library_service/models/db/user.py +++ b/library_service/models/db/user.py @@ -20,7 +20,7 @@ class User(UserBase, table=True): id: int | None = Field(default=None, primary_key=True, index=True) hashed_password: str = Field(nullable=False) is_2fa_enabled: bool = Field(default=False) - totp_secret: str | None = Field(default=None, max_length=64) + totp_secret: str | None = Field(default=None, max_length=80) recovery_code_hashes: str | None = Field(default=None, max_length=1500) recovery_codes_generated_at: datetime | None = Field(default=None) is_active: bool = Field(default=True) diff --git a/library_service/routers/auth.py b/library_service/routers/auth.py index ce27915..15fd319 100644 --- a/library_service/routers/auth.py +++ b/library_service/routers/auth.py @@ -1,5 +1,7 @@ """Модуль работы с авторизацией и аутентификацией пользователей""" +import base64 + from datetime import timedelta from typing import Annotated @@ -51,6 +53,7 @@ from library_service.auth import ( create_partial_token, RequirePartialAuth, verify_and_use_code, + cipher, ) @@ -250,9 +253,19 @@ def update_user_me( summary="Создание QR-кода TOTP 2FA", description="Генерирует секрет и QR-код для настройки TOTP", ) -def get_totp_qr_bitmap(auth: RequireAuth): +def get_totp_qr_bitmap( + current_user: RequireAuth, + session: Session = Depends(get_session), +): """Возвращает данные для настройки TOTP""" - return TOTPSetupResponse(**generate_totp_setup(auth.username)) + totp_data = generate_totp_setup(current_user.username) + encrypted = cipher.encrypt(totp_data["secret"].encode()) + + current_user.totp_secret = base64.b64encode(encrypted).decode() + session.add(current_user) + session.commit() + + return TOTPSetupResponse(**totp_data) @router.post( @@ -273,13 +286,23 @@ def enable_2fa( detail="2FA already enabled", ) + if not current_user.totp_secret: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail="Secret key not generated" + ) + if not verify_totp_code(secret, data.code): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid TOTP code", ) - current_user.totp_secret = secret + decrypted = cipher.decrypt(base64.b64decode(current_user.totp_secret.encode())) + if secret != decrypted.decode(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail="Incorret secret" + ) + current_user.is_2fa_enabled = True session.add(current_user) session.commit() @@ -339,7 +362,8 @@ def verify_2fa( verified = False if data.code and user.totp_secret: - if verify_totp_code(user.totp_secret, data.code): + decrypted = cipher.decrypt(base64.b64decode(user.totp_secret.encode())) + if verify_totp_code(decrypted.decode(), data.code): verified = True if not verified: diff --git a/library_service/routers/cap.py b/library_service/routers/cap.py index 6bedc03..bb83f51 100644 --- a/library_service/routers/cap.py +++ b/library_service/routers/cap.py @@ -21,9 +21,10 @@ from library_service.services.captcha import ( router = APIRouter(prefix="/cap", tags=["captcha"]) -@router.post("/challenge") +@router.post("/challenge", summary="Задача capjs") @limiter.limit("15/minute") async def challenge(request: Request, ip: str = Depends(get_ip)): + """Возвращает задачу capjs""" if challenges_by_ip[ip] >= MAX_CHALLENGES_PER_IP: raise HTTPException( status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="Too many challenges" @@ -50,9 +51,10 @@ async def challenge(request: Request, ip: str = Depends(get_ip)): return {"challenge": {"c": 50, "s": 32, "d": 4}, "token": token, "expires": expires} -@router.post("/redeem") +@router.post("/redeem", summary="Проверка задачи") @limiter.limit("30/minute") async def redeem(request: Request, payload: dict, ip: str = Depends(get_ip)): + """Возвращает capjs_token""" token = payload.get("token") solutions = payload.get("solutions", []) diff --git a/library_service/static/page/book.js b/library_service/static/page/book.js index ef4145a..01ca640 100644 --- a/library_service/static/page/book.js +++ b/library_service/static/page/book.js @@ -441,7 +441,7 @@ $(document).ready(() => { } try { - const data = await Api.get("/api/auth/users?skip=0&limit=500"); + const data = await Api.get("/api/users?skip=0&limit=500"); cachedUsers = data.users; renderUsersList(cachedUsers); } catch (error) { diff --git a/library_service/templates/api.html b/library_service/templates/api.html index 17ed01c..3a3ad9f 100644 --- a/library_service/templates/api.html +++ b/library_service/templates/api.html @@ -20,7 +20,11 @@ } ul { list-style: none; + list-style-type: none; + display: flex; padding: 0; + margin: 0; + gap: 20px; } li { margin: 15px 0; @@ -40,6 +44,46 @@ p { margin: 5px 0; } + #erDiagram { + position: relative; + width: 100%; + height: 420px; + border: 1px solid #ddd; + border-radius: 4px; + margin-top: 30px; + background: #fafafa; + overflow: hidden; + } + .er-table { + position: absolute; + width: 200px; + background: #fff; + border: 1px solid #3498db; + border-radius: 4px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); + font-size: 13px; + } + .er-table-header { + background: #3498db; + color: #fff; + padding: 6px 8px; + font-weight: bold; + } + .er-table-body { + padding: 6px 8px; + line-height: 1.4; + } + .er-field { + padding: 2px 0; + position: relative; + } + .relation-label { + font-size: 11px; + background: #fff; + padding: 1px 3px; + border-radius: 3px; + border: 1px solid #ccc; + } @@ -51,11 +95,234 @@

Status: {{ status }}

+ +

ER Diagram

+
+ + + diff --git a/pyproject.toml b/pyproject.toml index 25ce994..075b5ba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "LibraryAPI" -version = "0.5.0" +version = "0.6.0" description = "Это простое API для управления авторами, книгами и их жанрами." authors = [{ name = "wowlikon" }] readme = "README.md" diff --git a/uv.lock b/uv.lock index bde6f1c..e9de6a6 100644 --- a/uv.lock +++ b/uv.lock @@ -631,7 +631,7 @@ sdist = { url = "https://files.pythonhosted.org/packages/e8/ef/324f4a28ed0152a32 [[package]] name = "libraryapi" -version = "0.5.0" +version = "0.6.0" source = { editable = "." } dependencies = [ { name = "aiofiles" },