Улучшение документации и 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_USERNAME="admin"
# DEFAULT_ADMIN_EMAIL="admin@example.com" # DEFAULT_ADMIN_EMAIL="admin@example.com"
# DEFAULT_ADMIN_PASSWORD="password-is-generated-randomly-on-first-launch" # DEFAULT_ADMIN_PASSWORD="password-is-generated-randomly-on-first-launch"
SECRET_KEY="your-secret-key-change-in-production"
# JWT # JWT
ALGORITHM="HS256" ALGORITHM="HS256"
REFRESH_TOKEN_EXPIRE_DAYS="7" REFRESH_TOKEN_EXPIRE_DAYS="7"
ACCESS_TOKEN_EXPIRE_MINUTES="15" ACCESS_TOKEN_EXPIRE_MINUTES="15"
PARTIAL_TOKEN_EXPIRE_MINUTES="5" PARTIAL_TOKEN_EXPIRE_MINUTES="5"
SECRET_KEY="your-secret-key-change-in-production"
# Hash # Hash
ARGON2_TYPE="id" ARGON2_TYPE="id"
+12 -4
View File
@@ -144,10 +144,10 @@
#### **Пользователи** (`/api/users`) #### **Пользователи** (`/api/users`)
| Метод | Эндпоинт | Доступ | Описание | | Метод | Эндпоинт | Доступ | Описание |
|--------|-------------------------------|----------------|------------------------------| |--------|--------------------------------|----------------|------------------------------|
| POST | `/` | Админ | Создать нового пользователя | | POST | `/` | Админ | Создать нового пользователя |
| GET | `/` | Админ | Список всех пользователей | | GET | `/` | Админ | Список всех пользователей |
| GET | `/{id}` | Админ | Получить пользователя по ID | | GET | `/{id}` | Админ | Получить пользователя по ID |
| PUT | `/{id}` | Админ | Обновить пользователя по ID | | PUT | `/{id}` | Админ | Обновить пользователя по ID |
| DELETE | `/{id}` | Админ | Удалить пользователя по ID | | DELETE | `/{id}` | Админ | Удалить пользователя по ID |
@@ -156,6 +156,14 @@
| GET | `/roles` | Авторизованный | Список ролей в системе | | GET | `/roles` | Авторизованный | Список ролей в системе |
#### **CAPTCHA** (`/api/cap`)
| Метод | Эндпоинт | Доступ | Описание |
|--------|---------------|-----------|-----------------|
| POST | `/challenge` | Публичный | Создание задачи |
| POST | `/redeem` | Публичный | Проверка задачи |
#### **Прочее** (`/api`) #### **Прочее** (`/api`)
| Метод | Эндпоинт | Доступ | Описание | | Метод | Эндпоинт | Доступ | Описание |
+8
View File
@@ -16,6 +16,10 @@ from .core import (
RECOVERY_CODE_SEGMENT_BYTES, RECOVERY_CODE_SEGMENT_BYTES,
RECOVERY_MIN_REMAINING_WARNING, RECOVERY_MIN_REMAINING_WARNING,
RECOVERY_MAX_AGE_DAYS, RECOVERY_MAX_AGE_DAYS,
KeyDeriver,
deriver,
AES256Cipher,
cipher,
verify_password, verify_password,
get_password_hash, get_password_hash,
create_access_token, create_access_token,
@@ -75,6 +79,10 @@ __all__ = [
"RECOVERY_CODE_SEGMENT_BYTES", "RECOVERY_CODE_SEGMENT_BYTES",
"RECOVERY_MIN_REMAINING_WARNING", "RECOVERY_MIN_REMAINING_WARNING",
"RECOVERY_MAX_AGE_DAYS", "RECOVERY_MAX_AGE_DAYS",
"KeyDeriver",
"deriver",
"AES256Cipher",
"cipher",
"verify_password", "verify_password",
"get_password_hash", "get_password_hash",
"create_access_token", "create_access_token",
+67 -5
View File
@@ -1,10 +1,13 @@
"""Модуль основного функционала авторизации и аутентификации""" """Модуль основного функционала авторизации и аутентификации"""
import os
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Annotated from typing import Annotated
from uuid import uuid4 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 import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer from fastapi.security import OAuth2PasswordBearer
from jose import jwt, JWTError, ExpiredSignatureError from jose import jwt, JWTError, ExpiredSignatureError
@@ -17,7 +20,6 @@ from library_service.settings import get_session, get_logger
# Конфигурация JWT из переменных окружения # Конфигурация JWT из переменных окружения
SECRET_KEY = os.getenv("SECRET_KEY")
ALGORITHM = os.getenv("ALGORITHM", "HS256") ALGORITHM = os.getenv("ALGORITHM", "HS256")
PARTIAL_TOKEN_EXPIRE_MINUTES = int(os.getenv("PARTIAL_TOKEN_EXPIRE_MINUTES", "5")) PARTIAL_TOKEN_EXPIRE_MINUTES = int(os.getenv("PARTIAL_TOKEN_EXPIRE_MINUTES", "5"))
ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "15")) 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_MIN_REMAINING_WARNING = int(os.getenv("RECOVERY_MIN_REMAINING_WARNING", "3"))
RECOVERY_MAX_AGE_DAYS = int(os.getenv("RECOVERY_MAX_AGE_DAYS", "365")) RECOVERY_MAX_AGE_DAYS = int(os.getenv("RECOVERY_MAX_AGE_DAYS", "365"))
SECRET_KEY = os.getenv("SECRET_KEY")
# Получение логгера # Получение логгера
logger = get_logger() logger = get_logger()
# OAuth2 схема # OAuth2 схема
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/token") 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: if not SECRET_KEY:
raise RuntimeError("SECRET_KEY environment variable is required") 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( pwd_context = CryptContext(
schemes=["argon2"], schemes=["argon2"],
@@ -88,7 +150,7 @@ def _create_token(
} }
if token_type == "refresh": if token_type == "refresh":
to_encode.update({"jti": str(uuid4())}) 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: def create_partial_token(data: dict) -> str:
@@ -119,7 +181,7 @@ def decode_token(
headers={"WWW-Authenticate": "Bearer"}, headers={"WWW-Authenticate": "Bearer"},
) )
try: try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) payload = jwt.decode(token, jwt_key, algorithms=[ALGORITHM])
username: str | None = payload.get("sub") username: str | None = payload.get("sub")
user_id: int | None = payload.get("user_id") user_id: int | None = payload.get("user_id")
token_type: str | None = payload.get("type") 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) id: int | None = Field(default=None, primary_key=True, index=True)
hashed_password: str = Field(nullable=False) hashed_password: str = Field(nullable=False)
is_2fa_enabled: bool = Field(default=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_code_hashes: str | None = Field(default=None, max_length=1500)
recovery_codes_generated_at: datetime | None = Field(default=None) recovery_codes_generated_at: datetime | None = Field(default=None)
is_active: bool = Field(default=True) is_active: bool = Field(default=True)
+28 -4
View File
@@ -1,5 +1,7 @@
"""Модуль работы с авторизацией и аутентификацией пользователей""" """Модуль работы с авторизацией и аутентификацией пользователей"""
import base64
from datetime import timedelta from datetime import timedelta
from typing import Annotated from typing import Annotated
@@ -51,6 +53,7 @@ from library_service.auth import (
create_partial_token, create_partial_token,
RequirePartialAuth, RequirePartialAuth,
verify_and_use_code, verify_and_use_code,
cipher,
) )
@@ -250,9 +253,19 @@ def update_user_me(
summary="Создание QR-кода TOTP 2FA", summary="Создание QR-кода TOTP 2FA",
description="Генерирует секрет и QR-код для настройки TOTP", description="Генерирует секрет и QR-код для настройки TOTP",
) )
def get_totp_qr_bitmap(auth: RequireAuth): def get_totp_qr_bitmap(
current_user: RequireAuth,
session: Session = Depends(get_session),
):
"""Возвращает данные для настройки TOTP""" """Возвращает данные для настройки 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( @router.post(
@@ -273,13 +286,23 @@ def enable_2fa(
detail="2FA already enabled", 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): if not verify_totp_code(secret, data.code):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid TOTP code", 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 current_user.is_2fa_enabled = True
session.add(current_user) session.add(current_user)
session.commit() session.commit()
@@ -339,7 +362,8 @@ def verify_2fa(
verified = False verified = False
if data.code and user.totp_secret: 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 verified = True
if not verified: if not verified:
+4 -2
View File
@@ -21,9 +21,10 @@ from library_service.services.captcha import (
router = APIRouter(prefix="/cap", tags=["captcha"]) router = APIRouter(prefix="/cap", tags=["captcha"])
@router.post("/challenge") @router.post("/challenge", summary="Задача capjs")
@limiter.limit("15/minute") @limiter.limit("15/minute")
async def challenge(request: Request, ip: str = Depends(get_ip)): async def challenge(request: Request, ip: str = Depends(get_ip)):
"""Возвращает задачу capjs"""
if challenges_by_ip[ip] >= MAX_CHALLENGES_PER_IP: if challenges_by_ip[ip] >= MAX_CHALLENGES_PER_IP:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="Too many challenges" 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} return {"challenge": {"c": 50, "s": 32, "d": 4}, "token": token, "expires": expires}
@router.post("/redeem") @router.post("/redeem", summary="Проверка задачи")
@limiter.limit("30/minute") @limiter.limit("30/minute")
async def redeem(request: Request, payload: dict, ip: str = Depends(get_ip)): async def redeem(request: Request, payload: dict, ip: str = Depends(get_ip)):
"""Возвращает capjs_token"""
token = payload.get("token") token = payload.get("token")
solutions = payload.get("solutions", []) solutions = payload.get("solutions", [])
+1 -1
View File
@@ -441,7 +441,7 @@ $(document).ready(() => {
} }
try { 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; cachedUsers = data.users;
renderUsersList(cachedUsers); renderUsersList(cachedUsers);
} catch (error) { } catch (error) {
+269 -2
View File
@@ -20,7 +20,11 @@
} }
ul { ul {
list-style: none; list-style: none;
list-style-type: none;
display: flex;
padding: 0; padding: 0;
margin: 0;
gap: 20px;
} }
li { li {
margin: 15px 0; margin: 15px 0;
@@ -40,6 +44,46 @@
p { p {
margin: 5px 0; 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> </style>
</head> </head>
<body> <body>
@@ -51,11 +95,234 @@
<p>Status: {{ status }}</p> <p>Status: {{ status }}</p>
<ul> <ul>
<li><a href="/">Home page</a></li> <li><a href="/">Home page</a></li>
<li><a href="/docs">Swagger UI</a></li>
<li><a href="/redoc">ReDoc</a></li>
<li> <li>
<a href="https://github.com/wowlikon/LibraryAPI">Source Code</a> <a href="https://github.com/wowlikon/LibraryAPI">Source Code</a>
</li> </li>
<li><a href="/docs">Swagger UI</a></li>
<li><a href="/redoc">ReDoc</a></li>
</ul> </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> </body>
</html> </html>
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "LibraryAPI" name = "LibraryAPI"
version = "0.5.0" version = "0.6.0"
description = "Это простое API для управления авторами, книгами и их жанрами." description = "Это простое API для управления авторами, книгами и их жанрами."
authors = [{ name = "wowlikon" }] authors = [{ name = "wowlikon" }]
readme = "README.md" readme = "README.md"
Generated
+1 -1
View File
@@ -631,7 +631,7 @@ sdist = { url = "https://files.pythonhosted.org/packages/e8/ef/324f4a28ed0152a32
[[package]] [[package]]
name = "libraryapi" name = "libraryapi"
version = "0.5.0" version = "0.6.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "aiofiles" }, { name = "aiofiles" },