mirror of
https://github.com/wowlikon/LiB.git
synced 2026-02-04 04:31:09 +00:00
Улучшение документации и KDF с шифрованием totp
This commit is contained in:
@@ -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"
|
||||||
|
|||||||
@@ -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`)
|
||||||
|
|
||||||
| Метод | Эндпоинт | Доступ | Описание |
|
| Метод | Эндпоинт | Доступ | Описание |
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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", [])
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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" },
|
||||||
|
|||||||
Reference in New Issue
Block a user