Улучшение документации и KDF с шифрованием totp

This commit is contained in:
2026-01-24 10:52:08 +03:00
parent c1ac0ca246
commit ec1c32a5bd
11 changed files with 393 additions and 22 deletions
+1 -1
View File
@@ -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"
+12 -4
View File
@@ -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`)
| Метод | Эндпоинт | Доступ | Описание |
+8
View File
@@ -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",
+67 -5
View File
@@ -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")
+1 -1
View File
@@ -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)
+28 -4
View File
@@ -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:
+4 -2
View File
@@ -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", [])
+1 -1
View File
@@ -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) {
+269 -2
View File
@@ -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;
}
</style>
</head>
<body>
@@ -51,11 +95,234 @@
<p>Status: {{ status }}</p>
<ul>
<li><a href="/">Home page</a></li>
<li><a href="/docs">Swagger UI</a></li>
<li><a href="/redoc">ReDoc</a></li>
<li>
<a href="https://github.com/wowlikon/LibraryAPI">Source Code</a>
</li>
<li><a href="/docs">Swagger UI</a></li>
<li><a href="/redoc">ReDoc</a></li>
</ul>
<h2>ER Diagram</h2>
<div id="erDiagram"></div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jsPlumb/2.15.6/js/jsplumb.min.js"></script>
<script>
const diagramData = {
entities: [
{
id: "User",
title: "users",
fields: [
{ id: "id", label: "id (PK)" },
{ id: "username", label: "username" },
{ id: "email", label: "email" },
{ id: "full_name", label: "full_name" },
{ id: "is_active", label: "is_active" },
{ id: "is_verified", label: "is_verified" },
{ id: "is_2fa_enabled", label: "is_2fa_enabled" }
]
},
{
id: "Role",
title: "roles",
fields: [
{ id: "id", label: "id (PK)" },
{ id: "name", label: "name" },
{ id: "description", label: "description" },
{ id: "payroll", label: "payroll" }
]
},
{
id: "UserRole",
title: "user_roles",
fields: [
{ id: "user_id", label: "user_id (FK)" },
{ id: "role_id", label: "role_id (FK)" }
]
},
{
id: "Author",
title: "authors",
fields: [
{ id: "id", label: "id (PK)" },
{ id: "name", label: "name" }
]
},
{
id: "Book",
title: "books",
fields: [
{ id: "id", label: "id (PK)" },
{ id: "title", label: "title" },
{ id: "description", label: "description" },
{ id: "page_count", label: "page_count" },
{ id: "status", label: "status" }
]
},
{
id: "Genre",
title: "genres",
fields: [
{ id: "id", label: "id (PK)" },
{ id: "name", label: "name" }
]
},
{
id: "Loan",
title: "loans",
fields: [
{ id: "id", label: "id (PK)" },
{ id: "book_id", label: "book_id (FK)" },
{ id: "user_id", label: "user_id (FK)" },
{ id: "borrowed_at", label: "borrowed_at" },
{ id: "due_date", label: "due_date" },
{ id: "returned_at", label: "returned_at" }
]
},
{
id: "AuthorBook",
title: "authors_books",
fields: [
{ id: "author_id", label: "author_id (FK)" },
{ id: "book_id", label: "book_id (FK)" }
]
},
{
id: "GenreBook",
title: "genres_books",
fields: [
{ id: "genre_id", label: "genre_id (FK)" },
{ id: "book_id", label: "book_id (FK)" }
]
}
],
relations: [
{
fromEntity: "Loan",
fromField: "book_id",
toEntity: "Book",
toField: "id",
fromMultiplicity: "N",
toMultiplicity: "1"
},
{
fromEntity: "Loan",
fromField: "user_id",
toEntity: "User",
toField: "id",
fromMultiplicity: "N",
toMultiplicity: "1"
},
{
fromEntity: "AuthorBook",
fromField: "author_id",
toEntity: "Author",
toField: "id",
fromMultiplicity: "N",
toMultiplicity: "1"
},
{
fromEntity: "AuthorBook",
fromField: "book_id",
toEntity: "Book",
toField: "id",
fromMultiplicity: "N",
toMultiplicity: "1"
},
{
fromEntity: "GenreBook",
fromField: "genre_id",
toEntity: "Genre",
toField: "id",
fromMultiplicity: "N",
toMultiplicity: "1"
},
{
fromEntity: "GenreBook",
fromField: "book_id",
toEntity: "Book",
toField: "id",
fromMultiplicity: "N",
toMultiplicity: "1"
},
{
fromEntity: "UserRole",
fromField: "user_id",
toEntity: "User",
toField: "id",
fromMultiplicity: "N",
toMultiplicity: "1"
},
{
fromEntity: "UserRole",
fromField: "role_id",
toEntity: "Role",
toField: "id",
fromMultiplicity: "N",
toMultiplicity: "1"
}
]
};
jsPlumb.ready(function () {
jsPlumb.setContainer("erDiagram");
const container = document.getElementById("erDiagram");
const baseLeft = 40;
const baseTop = 80;
const spacingX = 240;
diagramData.entities.forEach((entity, index) => {
const table = document.createElement("div");
table.className = "er-table";
table.id = "table-" + entity.id;
table.style.top = baseTop + "px";
table.style.left = baseLeft + index * spacingX + "px";
const header = document.createElement("div");
header.className = "er-table-header";
header.textContent = entity.title || entity.id;
const body = document.createElement("div");
body.className = "er-table-body";
entity.fields.forEach(field => {
const row = document.createElement("div");
row.className = "er-field";
row.id = "field-" + entity.id + "-" + field.id;
row.textContent = field.label || field.id;
body.appendChild(row);
});
table.appendChild(header);
table.appendChild(body);
container.appendChild(table);
});
const common = {
endpoint: "Dot",
endpointStyle: { radius: 4, fill: "#3498db" },
connector: ["Flowchart", { cornerRadius: 5 }],
paintStyle: { stroke: "#3498db", strokeWidth: 2 },
hoverPaintStyle: { stroke: "#2980b9", strokeWidth: 2 },
anchor: ["Continuous", { faces: ["left", "right"] }]
};
const tableIds = diagramData.entities.map(e => "table-" + e.id);
jsPlumb.draggable(tableIds, { containment: "parent" });
diagramData.relations.forEach(rel => {
jsPlumb.connect({
source: "field-" + rel.fromEntity + "-" + rel.fromField,
target: "field-" + rel.toEntity + "-" + rel.toField,
overlays: [
["Label", { label: rel.fromMultiplicity || "", location: 0.2, cssClass: "relation-label" }],
["Label", { label: rel.toMultiplicity || "", location: 0.8, cssClass: "relation-label" }]
],
...common
});
});
});
</script>
</body>
</html>
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "LibraryAPI"
version = "0.5.0"
version = "0.6.0"
description = "Это простое API для управления авторами, книгами и их жанрами."
authors = [{ name = "wowlikon" }]
readme = "README.md"
Generated
+1 -1
View File
@@ -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" },