mirror of
https://github.com/wowlikon/LiB.git
synced 2026-02-04 04:31:09 +00:00
Добавление страницы 2FA, poetry -> uv
This commit is contained in:
@@ -1,14 +1,23 @@
|
||||
ALGORITHM = "HS256"
|
||||
REFRESH_TOKEN_EXPIRE_DAYS = "7"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES = "20"
|
||||
SECRET_KEY = "your-secret-key-change-in-production"
|
||||
# Postgres
|
||||
POSTGRES_HOST="db"
|
||||
POSTGRES_PORT="5432"
|
||||
POSTGRES_USER="postgres"
|
||||
POSTGRES_PASSWORD="postgres"
|
||||
POSTGRES_DB="lib"
|
||||
|
||||
# DEFAULT_ADMIN_USERNAME="admin"
|
||||
# DEFAULT_ADMIN_EMAIL="admin@example.com"
|
||||
# DEFAULT_ADMIN_PASSWORD="password-is-generated-randomly-on-first-launch"
|
||||
|
||||
POSTGRES_HOST = "localhost"
|
||||
POSTGRES_PORT = "5432"
|
||||
POSTGRES_USER = "postgres"
|
||||
POSTGRES_PASSWORD = "postgres"
|
||||
POSTGRES_DB = "lib"
|
||||
# JWT
|
||||
ALGORITHM="HS256"
|
||||
REFRESH_TOKEN_EXPIRE_DAYS="7"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES="15"
|
||||
# SECRET_KEY="your-secret-key-change-in-production"
|
||||
|
||||
# Hash
|
||||
ARGON2_TYPE="id"
|
||||
ARGON2_TIME_COST="3"
|
||||
ARGON2_MEMORY_COST="65536"
|
||||
ARGON2_PARALLELISM="4"
|
||||
ARGON2_SALT_LENGTH="16"
|
||||
|
||||
+12
-8
@@ -3,6 +3,9 @@ FROM python:3.12-slim
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
ENV UV_PROJECT_ENVIRONMENT="/opt/venv"
|
||||
ENV PATH="/opt/venv/bin:$PATH"
|
||||
|
||||
WORKDIR /code
|
||||
|
||||
RUN apt-get update \
|
||||
@@ -10,20 +13,21 @@ RUN apt-get update \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN pip install poetry
|
||||
RUN poetry config virtualenvs.create false
|
||||
|
||||
COPY ./pyproject.toml ./poetry.lock* /code/
|
||||
|
||||
RUN poetry install --with dev --no-root --no-interaction
|
||||
RUN pip install uv
|
||||
COPY ./README.md ./pyproject.toml ./uv.lock* /code/
|
||||
RUN uv sync --group dev --no-install-project
|
||||
|
||||
COPY ./library_service /code/library_service
|
||||
COPY ./alembic.ini /code/
|
||||
COPY ./data.py /code/
|
||||
|
||||
RUN useradd app && chown -R app:app /code
|
||||
RUN useradd app && \
|
||||
chown -R app:app /code && \
|
||||
chown -R app:app /opt/venv
|
||||
USER app
|
||||
|
||||
ENV PYTHONPATH=/code
|
||||
|
||||
CMD ["uvicorn", "library_service.main:app", "--host", "0.0.0.0", "--port", "8000", "--forwarded-allow-ips=\"*\""]
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "library_service.main:app", "--host", "0.0.0.0", "--port", "8000", "--forwarded-allow-ips=*"]
|
||||
|
||||
+4
-2
@@ -23,13 +23,15 @@ services:
|
||||
build: .
|
||||
container_name: api
|
||||
restart: unless-stopped
|
||||
command: bash -c "uvicorn library_service.main:app --host 0.0.0.0 --port 8000 --forwarded-allow-ips=\"*\""
|
||||
command: bash -c "uvicorn library_service.main:app --host 0.0.0.0 --port 8000 --forwarded-allow-ips=*"
|
||||
logging:
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
networks:
|
||||
- proxy
|
||||
ports:
|
||||
- 8000:8000
|
||||
env_file:
|
||||
- ./.env
|
||||
volumes:
|
||||
@@ -42,7 +44,7 @@ services:
|
||||
container_name: tests
|
||||
build: .
|
||||
command: bash -c "pytest tests"
|
||||
restart: unless-stopped
|
||||
restart: no
|
||||
logging:
|
||||
options:
|
||||
max-size: "10m"
|
||||
|
||||
+88
-24
@@ -1,25 +1,36 @@
|
||||
"""Модуль авторизации и аутентификации"""
|
||||
|
||||
import os
|
||||
import base64
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Annotated
|
||||
|
||||
from uuid import uuid4
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from jose import JWTError, jwt
|
||||
from jose import jwt, JWTError, ExpiredSignatureError
|
||||
from passlib.context import CryptContext
|
||||
from sqlmodel import Session, select
|
||||
import pyotp
|
||||
import qrcode
|
||||
|
||||
from library_service.models.db import Role, User
|
||||
from library_service.models.dto import TokenData
|
||||
from library_service.settings import get_session, get_logger
|
||||
|
||||
|
||||
# Конфигурация из переменных окружения
|
||||
# Конфигурация JWT из переменных окружения
|
||||
SECRET_KEY = os.getenv("SECRET_KEY")
|
||||
ALGORITHM = os.getenv("ALGORITHM", "HS256")
|
||||
SECRET_KEY = os.getenv("SECRET_KEY", "your-secret-key-change-in-production")
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "30"))
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "15"))
|
||||
REFRESH_TOKEN_EXPIRE_DAYS = int(os.getenv("REFRESH_TOKEN_EXPIRE_DAYS", "7"))
|
||||
|
||||
# Конфигурация хэширования паролей из переменных окружения
|
||||
ARGON2_TYPE = os.getenv("ARGON2_TYPE", "id")
|
||||
ARGON2_TIME_COST = int(os.getenv("ARGON2_TIME_COST", "3"))
|
||||
ARGON2_MEMORY_COST = int(os.getenv("ARGON2_MEMORY_COST", "65536"))
|
||||
ARGON2_PARALLELISM = int(os.getenv("ARGON2_PARALLELISM", "4"))
|
||||
ARGON2_SALT_LENGTH = int(os.getenv("ARGON2_SALT_LENGTH", "16"))
|
||||
|
||||
# Получение логгера
|
||||
logger = get_logger()
|
||||
@@ -27,8 +38,20 @@ logger = get_logger()
|
||||
# OAuth2 схема
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/token")
|
||||
|
||||
# Проверка секретного ключа
|
||||
if not SECRET_KEY:
|
||||
raise RuntimeError("SECRET_KEY environment variable is required")
|
||||
|
||||
# Хэширование паролей
|
||||
pwd_context = CryptContext(schemes=["argon2"], deprecated="auto")
|
||||
pwd_context = CryptContext(
|
||||
schemes=["argon2"],
|
||||
deprecated="auto",
|
||||
argon2__type=ARGON2_TYPE,
|
||||
argon2__time_cost=ARGON2_TIME_COST,
|
||||
argon2__memory_cost=ARGON2_MEMORY_COST,
|
||||
argon2__parallelism=ARGON2_PARALLELISM,
|
||||
argon2__salt_len=ARGON2_SALT_LENGTH,
|
||||
)
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
@@ -41,27 +64,24 @@ def get_password_hash(password: str) -> str:
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def _create_token(data: dict, expires_delta: timedelta, token_type: str) -> str:
|
||||
"""Базовая функция создания токена"""
|
||||
now = datetime.now(timezone.utc)
|
||||
to_encode = {**data, "iat": now, "exp": now + expires_delta, "type": token_type}
|
||||
if token_type == "refresh":
|
||||
to_encode.update({"jti": str(uuid4())})
|
||||
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||
|
||||
|
||||
def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str:
|
||||
"""Создает JWT access токен"""
|
||||
to_encode = data.copy()
|
||||
if expires_delta:
|
||||
expire = datetime.now(timezone.utc) + expires_delta
|
||||
else:
|
||||
expire = datetime.now(timezone.utc) + timedelta(
|
||||
minutes=ACCESS_TOKEN_EXPIRE_MINUTES
|
||||
)
|
||||
to_encode.update({"exp": expire, "type": "access"})
|
||||
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||
return encoded_jwt
|
||||
delta = expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
return _create_token(data, delta, "access")
|
||||
|
||||
|
||||
def create_refresh_token(data: dict) -> str:
|
||||
"""Создает JWT refresh токен"""
|
||||
to_encode = data.copy()
|
||||
expire = datetime.now(timezone.utc) + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
|
||||
to_encode.update({"exp": expire, "type": "refresh"})
|
||||
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||
return encoded_jwt
|
||||
return _create_token(data, timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS), "refresh")
|
||||
|
||||
|
||||
def decode_token(token: str, expected_type: str = "access") -> TokenData:
|
||||
@@ -82,6 +102,9 @@ def decode_token(token: str, expected_type: str = "access") -> TokenData:
|
||||
token_error.detail = "Could not validate credentials"
|
||||
raise token_error
|
||||
return TokenData(username=username, user_id=user_id)
|
||||
except ExpiredSignatureError:
|
||||
token_error.detail = "Token expired"
|
||||
raise token_error
|
||||
except JWTError:
|
||||
token_error.detail = "Could not validate credentials"
|
||||
raise token_error
|
||||
@@ -141,6 +164,7 @@ def require_role(role_name: str):
|
||||
|
||||
def require_any_role(allowed_roles: list[str]):
|
||||
"""Создает dependency для проверки наличия хотя бы одной из ролей"""
|
||||
|
||||
def role_checker(current_user: User = Depends(get_current_active_user)) -> User:
|
||||
user_roles = {role.name for role in current_user.roles}
|
||||
if not (user_roles & set(allowed_roles)):
|
||||
@@ -149,6 +173,7 @@ def require_any_role(allowed_roles: list[str]):
|
||||
detail=f"Requires one of roles: {allowed_roles}",
|
||||
)
|
||||
return current_user
|
||||
|
||||
return role_checker
|
||||
|
||||
|
||||
@@ -166,7 +191,6 @@ def is_user_staff(user: User) -> bool:
|
||||
return bool(roles & {"admin", "librarian"})
|
||||
|
||||
|
||||
|
||||
def is_user_admin(user: User) -> bool:
|
||||
"""Проверяет, является ли пользователь администратором"""
|
||||
roles = {role.name for role in user.roles}
|
||||
@@ -207,7 +231,9 @@ def seed_admin(session: Session, admin_role: Role) -> User | None:
|
||||
).all()
|
||||
|
||||
if existing_admins:
|
||||
logger.info(f"[=] Admin already exists: {existing_admins[0].username}, skipping creation")
|
||||
logger.info(
|
||||
f"[=] Admin already exists: {existing_admins[0].username}, skipping creation"
|
||||
)
|
||||
return None
|
||||
|
||||
admin_username = os.getenv("DEFAULT_ADMIN_USERNAME", "admin")
|
||||
@@ -217,6 +243,7 @@ def seed_admin(session: Session, admin_role: Role) -> User | None:
|
||||
generated = False
|
||||
if not admin_password:
|
||||
import secrets
|
||||
|
||||
admin_password = secrets.token_urlsafe(16)
|
||||
generated = True
|
||||
|
||||
@@ -237,10 +264,10 @@ def seed_admin(session: Session, admin_role: Role) -> User | None:
|
||||
logger.info(f"[+] Created admin user: {admin_username}")
|
||||
|
||||
if generated:
|
||||
logger.warning("=" * 50)
|
||||
logger.warning("=" * 52)
|
||||
logger.warning(f"[!] GENERATED ADMIN PASSWORD: {admin_password}")
|
||||
logger.warning("[!] Save this password! It won't be shown again!")
|
||||
logger.warning("=" * 50)
|
||||
logger.warning("=" * 52)
|
||||
|
||||
return admin_user
|
||||
|
||||
@@ -249,3 +276,40 @@ def run_seeds(session: Session) -> None:
|
||||
"""Запускает создание ролей и администратора"""
|
||||
roles = seed_roles(session)
|
||||
seed_admin(session, roles["admin"])
|
||||
|
||||
|
||||
def qr_to_bitmap_b64(data: str) -> dict:
|
||||
"""
|
||||
Конвертирует данные в QR-код и возвращает как base64 bitmap.
|
||||
0 = чёрный, 1 = белый
|
||||
"""
|
||||
qr = qrcode.QRCode(
|
||||
version=1,
|
||||
error_correction=qrcode.constants.ERROR_CORRECT_L,
|
||||
box_size=1,
|
||||
border=0,
|
||||
)
|
||||
qr.add_data(data)
|
||||
qr.make(fit=True)
|
||||
|
||||
matrix = qr.get_matrix()
|
||||
size = len(matrix)
|
||||
|
||||
bits = []
|
||||
for row in matrix:
|
||||
for cell in row:
|
||||
bits.append(0 if cell else 1)
|
||||
|
||||
padding = (8 - len(bits) % 8) % 8
|
||||
bits.extend([0] * padding)
|
||||
|
||||
bytes_array = bytearray()
|
||||
for i in range(0, len(bits), 8):
|
||||
byte = 0
|
||||
for j in range(8):
|
||||
byte = (byte << 1) | bits[i + j]
|
||||
bytes_array.append(byte)
|
||||
|
||||
b64 = base64.b64encode(bytes_array).decode("ascii")
|
||||
|
||||
return {"size": size, "padding": padding, "bitmap_b64": b64}
|
||||
|
||||
@@ -20,7 +20,7 @@ from library_service.settings import (
|
||||
get_logger,
|
||||
)
|
||||
|
||||
SKIP_LOGGING_PATHS = frozenset({"/health", "/favicon.ico"})
|
||||
SKIP_LOGGING_PATHS = frozenset({"/favicon.ico", "/favicon.svg"})
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
|
||||
@@ -1,19 +1,40 @@
|
||||
"""Модуль работы с авторизацией и аутентификацией пользователей"""
|
||||
|
||||
from datetime import timedelta
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, status
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, status, Request
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from sqlmodel import Session, select
|
||||
import pyotp
|
||||
|
||||
from library_service.models.db import Role, User
|
||||
from library_service.models.dto import Token, UserCreate, UserRead, UserUpdate, UserList, RoleRead, RoleList
|
||||
from library_service.models.dto import (
|
||||
Token,
|
||||
UserCreate,
|
||||
UserRead,
|
||||
UserUpdate,
|
||||
UserList,
|
||||
RoleRead,
|
||||
RoleList,
|
||||
)
|
||||
from library_service.settings import get_session
|
||||
from library_service.auth import (ACCESS_TOKEN_EXPIRE_MINUTES, RequireAuth, RequireAdmin, RequireStaff,
|
||||
authenticate_user, get_password_hash, decode_token,
|
||||
create_access_token, create_refresh_token)
|
||||
|
||||
from library_service.auth import (
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES,
|
||||
RequireAuth,
|
||||
RequireAdmin,
|
||||
RequireStaff,
|
||||
authenticate_user,
|
||||
get_password_hash,
|
||||
decode_token,
|
||||
create_access_token,
|
||||
create_refresh_token,
|
||||
qr_to_bitmap_b64,
|
||||
)
|
||||
from pathlib import Path
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
templates = Jinja2Templates(directory=Path(__file__).parent.parent / "templates")
|
||||
router = APIRouter(prefix="/auth", tags=["authentication"])
|
||||
|
||||
|
||||
@@ -45,7 +66,7 @@ def register(user_data: UserCreate, session: Session = Depends(get_session)):
|
||||
|
||||
db_user = User(
|
||||
**user_data.model_dump(exclude={"password"}),
|
||||
hashed_password=get_password_hash(user_data.password)
|
||||
hashed_password=get_password_hash(user_data.password),
|
||||
)
|
||||
|
||||
default_role = session.exec(select(Role).where(Role.name == "member")).first()
|
||||
@@ -305,3 +326,22 @@ def get_roles(
|
||||
roles=[RoleRead(**role.model_dump(exclude=exclude)) for role in roles],
|
||||
total=len(roles),
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/2fa",
|
||||
summary="Создание QR-кода TOTP 2FA",
|
||||
description="Получить информацию о текущем авторизованном пользователе",
|
||||
)
|
||||
def get_totp_qr_bitmap(auth: RequireAuth):
|
||||
"""Возвращает qr-код bitmap"""
|
||||
issuer = "issuer"
|
||||
username = auth.username
|
||||
secret = pyotp.random_base32()
|
||||
|
||||
totp = pyotp.TOTP(secret)
|
||||
provisioning_uri = totp.provisioning_uri(name=username, issuer_name=issuer)
|
||||
|
||||
bitmap_data = qr_to_bitmap_b64(provisioning_uri)
|
||||
|
||||
return {"secret": secret, "username": username, "issuer": issuer, **bitmap_data}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Модуль прочих эндпоинтов"""
|
||||
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict
|
||||
@@ -24,7 +25,7 @@ def get_info(app) -> Dict:
|
||||
"app_info": {
|
||||
"title": app.title,
|
||||
"version": app.version,
|
||||
"description": app.description.rsplit('|', 1)[0],
|
||||
"description": app.description.rsplit("|", 1)[0],
|
||||
},
|
||||
"server_time": datetime.now().isoformat(),
|
||||
}
|
||||
@@ -102,6 +103,12 @@ async def auth(request: Request):
|
||||
return templates.TemplateResponse(request, "auth.html")
|
||||
|
||||
|
||||
@router.get("/set-2fa", include_in_schema=False)
|
||||
async def set2fa(request: Request):
|
||||
"""Рендерит страницу установки двухфакторной аутентификации"""
|
||||
return templates.TemplateResponse(request, "2fa.html")
|
||||
|
||||
|
||||
@router.get("/profile", include_in_schema=False)
|
||||
async def profile(request: Request):
|
||||
"""Рендерит страницу профиля пользователя"""
|
||||
@@ -167,9 +174,11 @@ async def api_stats(session: Session = Depends(get_session)):
|
||||
books = select(func.count()).select_from(Book)
|
||||
genres = select(func.count()).select_from(Genre)
|
||||
users = select(func.count()).select_from(User)
|
||||
return JSONResponse(content={
|
||||
return JSONResponse(
|
||||
content={
|
||||
"authors": session.exec(authors).one(),
|
||||
"books": session.exec(books).one(),
|
||||
"genres": session.exec(genres).one(),
|
||||
"users": session.exec(users).one(),
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Модуль настроек проекта"""
|
||||
|
||||
import os, logging
|
||||
from pathlib import Path
|
||||
|
||||
@@ -65,11 +66,11 @@ OPENAPI_TAGS = [
|
||||
|
||||
def get_app(lifespan=None, /) -> FastAPI:
|
||||
"""Возвращает экземпляр FastAPI приложения"""
|
||||
poetry_cfg = _pyproject["tool"]["poetry"]
|
||||
project_cfg = _pyproject["project"]
|
||||
return FastAPI(
|
||||
title=poetry_cfg["name"],
|
||||
description=f"{poetry_cfg['description']} | [Вернуться на главную](/)",
|
||||
version=poetry_cfg["version"],
|
||||
title=project_cfg["name"],
|
||||
description=f"{project_cfg['description']} | [Вернуться на главную](/)",
|
||||
version=project_cfg["version"],
|
||||
lifespan=lifespan,
|
||||
openapi_tags=OPENAPI_TAGS,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,461 @@
|
||||
$(async () => {
|
||||
let secretKey = "";
|
||||
|
||||
try {
|
||||
const data = await Api.get("/api/auth/2fa");
|
||||
secretKey = data.secret;
|
||||
$("#secret-code-display").text(secretKey);
|
||||
|
||||
const config = {
|
||||
cellSize: 10,
|
||||
radius: 4,
|
||||
strokeWidth: 1.5,
|
||||
color: "#374151",
|
||||
arcDur: 500,
|
||||
arcDelayStep: 10,
|
||||
fillDur: 300,
|
||||
fillDelayStep: 10,
|
||||
squareDur: 800,
|
||||
shrinkDur: 300,
|
||||
moveDur: 800,
|
||||
shrinkFactor: 0.9,
|
||||
moveFactor: 0.3,
|
||||
};
|
||||
|
||||
const grid = decodeBitmapToGrid(data.bitmap_b64, data.size, data.padding);
|
||||
const svgHTML = AnimationLib.generateSVG(grid, config);
|
||||
|
||||
const $container = $("#qr-container");
|
||||
$container.find(".loader").remove();
|
||||
$container.prepend(svgHTML);
|
||||
|
||||
AnimationLib.animateCircles(grid, config);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
Utils.showToast("Ошибка загрузки данных 2FA", "error");
|
||||
$("#qr-container").html(
|
||||
'<div class="text-red-500 text-sm">Ошибка загрузки</div>',
|
||||
);
|
||||
}
|
||||
|
||||
$("#secret-copy-btn").on("click", function () {
|
||||
if (!secretKey) return;
|
||||
navigator.clipboard.writeText(secretKey).then(() => {
|
||||
Utils.showToast("Код скопирован", "success");
|
||||
});
|
||||
});
|
||||
|
||||
const $inputs = $(".totp-digit");
|
||||
const $submitBtn = $("#verify-btn");
|
||||
const $msg = $("#form-message");
|
||||
|
||||
let digits = $inputs.map((_, el) => $(el).val()).get();
|
||||
while (digits.length < 6) digits.push("");
|
||||
|
||||
function updateDigitsState() {
|
||||
digits = $inputs.map((_, el) => $(el).val()).get();
|
||||
}
|
||||
|
||||
function checkCompletion() {
|
||||
updateDigitsState();
|
||||
const isComplete = digits.every((d) => d.length === 1);
|
||||
if (isComplete) {
|
||||
$submitBtn.prop("disabled", false);
|
||||
$msg.text("").removeClass("text-red-600 text-green-600");
|
||||
} else {
|
||||
$submitBtn.prop("disabled", true);
|
||||
}
|
||||
return isComplete;
|
||||
}
|
||||
|
||||
function getTargetFocusIndex() {
|
||||
const firstEmptyIndex = digits.findIndex((d) => d === "");
|
||||
return firstEmptyIndex === -1 ? 5 : firstEmptyIndex;
|
||||
}
|
||||
|
||||
$inputs.on("focus click", function (e) {
|
||||
const targetIndex = getTargetFocusIndex();
|
||||
const currentIndex = $(this).data("index");
|
||||
|
||||
if (currentIndex !== targetIndex) {
|
||||
e.preventDefault();
|
||||
setTimeout(() => {
|
||||
$inputs.eq(targetIndex).trigger("focus");
|
||||
const val = $inputs.eq(targetIndex).val();
|
||||
$inputs.eq(targetIndex).val("").val(val);
|
||||
}, 0);
|
||||
}
|
||||
});
|
||||
|
||||
$inputs.on("input", function (e) {
|
||||
const index = parseInt($(this).data("index"));
|
||||
const val = $(this).val();
|
||||
const numericVal = val.replace(/\D/g, "");
|
||||
|
||||
if (!numericVal) {
|
||||
$(this).val("");
|
||||
digits[index] = "";
|
||||
return;
|
||||
}
|
||||
|
||||
const digit = numericVal.slice(-1);
|
||||
$(this).val(digit);
|
||||
digits[index] = digit;
|
||||
|
||||
const targetIndex = getTargetFocusIndex();
|
||||
$inputs.eq(targetIndex).trigger("focus");
|
||||
|
||||
checkCompletion();
|
||||
});
|
||||
|
||||
$inputs.on("keydown", function (e) {
|
||||
const index = parseInt($(this).data("index"));
|
||||
|
||||
if (e.key === "Backspace" || e.key === "Delete") {
|
||||
e.preventDefault();
|
||||
|
||||
const currentVal = $(this).val();
|
||||
|
||||
if (currentVal) {
|
||||
$(this).val("");
|
||||
digits[index] = "";
|
||||
} else {
|
||||
if (index > 0) {
|
||||
const prevIndex = index - 1;
|
||||
$inputs.eq(prevIndex).val("");
|
||||
digits[prevIndex] = "";
|
||||
$inputs.eq(prevIndex).trigger("focus");
|
||||
}
|
||||
}
|
||||
checkCompletion();
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
$inputs.on("paste", function (e) {
|
||||
e.preventDefault();
|
||||
const clipboardData =
|
||||
(e.originalEvent || e).clipboardData || window.clipboardData;
|
||||
const pastedData = clipboardData
|
||||
.getData("text")
|
||||
.replace(/\D/g, "")
|
||||
.slice(0, 6);
|
||||
|
||||
if (pastedData) {
|
||||
let charIdx = 0;
|
||||
let startIndex = 0;
|
||||
if (pastedData.length === 6) {
|
||||
startIndex = 0;
|
||||
} else {
|
||||
startIndex = digits.findIndex((d) => d === "");
|
||||
if (startIndex === -1) startIndex = 0;
|
||||
}
|
||||
|
||||
for (let i = startIndex; i < 6 && charIdx < pastedData.length; i++) {
|
||||
digits[i] = pastedData[charIdx];
|
||||
$inputs.eq(i).val(pastedData[charIdx]);
|
||||
charIdx++;
|
||||
}
|
||||
|
||||
checkCompletion();
|
||||
|
||||
const nextFocus = getTargetFocusIndex();
|
||||
$inputs.eq(nextFocus).trigger("focus");
|
||||
}
|
||||
});
|
||||
|
||||
$("#totp-form").on("submit", async function (e) {
|
||||
e.preventDefault();
|
||||
if (!checkCompletion()) return;
|
||||
|
||||
const code = digits.join("");
|
||||
$submitBtn.prop("disabled", true).text("Проверка...");
|
||||
$msg.text("").attr("class", "mb-4 text-center text-sm min-h-[20px]");
|
||||
|
||||
try {
|
||||
await Api.post("/api/auth/2fa/verify", {
|
||||
code: code,
|
||||
secret: secretKey,
|
||||
});
|
||||
|
||||
$msg.text("Код принят!").addClass("text-green-600");
|
||||
Utils.showToast("2FA успешно активирована", "success");
|
||||
|
||||
setTimeout(() => {
|
||||
window.location.href = "/profile";
|
||||
}, 1000);
|
||||
} catch (err) {
|
||||
const errorText = err.message || "Неверный код";
|
||||
$msg.text(errorText).addClass("text-red-600");
|
||||
$submitBtn.prop("disabled", false).text("Подтвердить");
|
||||
}
|
||||
});
|
||||
|
||||
checkCompletion();
|
||||
});
|
||||
|
||||
function decodeBitmapToGrid(b64Data, size, padding) {
|
||||
const binaryString = atob(b64Data);
|
||||
const bytes = new Uint8Array(binaryString.length);
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
const grid = [];
|
||||
let bitIndex = 0;
|
||||
for (let r = 0; r < size; r++) {
|
||||
const row = [];
|
||||
for (let c = 0; c < size; c++) {
|
||||
const bytePos = Math.floor(bitIndex / 8);
|
||||
const bitPos = 7 - (bitIndex % 8);
|
||||
if (bytePos < bytes.length) {
|
||||
const bit = (bytes[bytePos] >> bitPos) & 1;
|
||||
row.push(bit === 0);
|
||||
} else {
|
||||
row.push(false);
|
||||
}
|
||||
bitIndex++;
|
||||
}
|
||||
grid.push(row);
|
||||
}
|
||||
return grid;
|
||||
}
|
||||
|
||||
const AnimationLib = {
|
||||
generateSVG(grid, config) {
|
||||
const { cellSize, radius, strokeWidth, color } = config;
|
||||
const width = grid[0].length * cellSize;
|
||||
const height = grid.length * cellSize;
|
||||
|
||||
let svg = `<svg width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" xmlns="http://www.w3.org/2000/svg" class="mx-auto block" style="transition: all 0.5s ease;">`;
|
||||
for (let row = 0; row < grid.length; row++) {
|
||||
for (let col = 0; col < grid[row].length; col++) {
|
||||
const cx = col * cellSize + cellSize / 2;
|
||||
const cy = row * cellSize + cellSize / 2;
|
||||
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const isClockwise = (row + col) % 2 === 0;
|
||||
const initialOffset = isClockwise ? circumference : -circumference;
|
||||
|
||||
const squareX = cx - radius;
|
||||
const squareY = cy - radius;
|
||||
const squareSize = 2 * radius;
|
||||
|
||||
svg += `<rect x="${squareX}" y="${squareY}" width="${squareSize}" height="${squareSize}" rx="${radius}" ry="${radius}" fill="${color}" opacity="0" id="square_${row}_${col}"></rect>`;
|
||||
svg += `<circle cx="${cx}" cy="${cy}" r="${radius}" fill="none" stroke="${color}" stroke-width="${strokeWidth}" stroke-dasharray="${circumference}" stroke-dashoffset="${initialOffset}" id="circle_${row}_${col}"></circle>`;
|
||||
if (grid[row][col]) {
|
||||
svg += `<circle cx="${cx}" cy="${cy}" r="0" fill="${color}" id="inner_${row}_${col}"></circle>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
svg += "</svg>";
|
||||
return svg;
|
||||
},
|
||||
|
||||
animateCircles(grid, config) {
|
||||
const {
|
||||
radius,
|
||||
cellSize,
|
||||
arcDur,
|
||||
arcDelayStep,
|
||||
fillDur,
|
||||
fillDelayStep,
|
||||
squareDur,
|
||||
shrinkDur,
|
||||
moveDur,
|
||||
shrinkFactor,
|
||||
moveFactor,
|
||||
} = config;
|
||||
|
||||
const rows = grid.length;
|
||||
const cols = grid[0].length;
|
||||
const centerRow = Math.floor(rows / 2);
|
||||
const centerCol = Math.floor(cols / 2);
|
||||
const centerX = centerCol * cellSize + cellSize / 2 - radius;
|
||||
const centerY = centerRow * cellSize + cellSize / 2 - radius;
|
||||
|
||||
const elements = [];
|
||||
for (let row = 0; row < rows; row++) {
|
||||
elements[row] = [];
|
||||
for (let col = 0; col < cols; col++) {
|
||||
elements[row][col] = {
|
||||
circle: document.getElementById(`circle_${row}_${col}`),
|
||||
square: document.getElementById(`square_${row}_${col}`),
|
||||
inner: grid[row][col]
|
||||
? document.getElementById(`inner_${row}_${col}`)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
for (let row = 0; row < rows; row++) {
|
||||
for (let col = 0; col < cols; col++) {
|
||||
const { circle } = elements[row][col];
|
||||
if (circle) {
|
||||
const isClockwise = (row + col) % 2 === 0;
|
||||
setTimeout(
|
||||
() => {
|
||||
this.rafAnimate(
|
||||
circle,
|
||||
"stroke-dashoffset",
|
||||
isClockwise ? 2 * Math.PI * radius : -2 * Math.PI * radius,
|
||||
0,
|
||||
arcDur,
|
||||
);
|
||||
},
|
||||
(row + col) * arcDelayStep,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const maxDelayFirst = (rows + cols - 2) * arcDelayStep;
|
||||
|
||||
setTimeout(() => {
|
||||
let maxDist = 0;
|
||||
const fills = [];
|
||||
for (let r = 0; r < rows; r++) {
|
||||
for (let c = 0; c < cols; c++) {
|
||||
if (grid[r][c]) {
|
||||
const d = Math.sqrt((r - centerRow) ** 2 + (c - centerCol) ** 2);
|
||||
fills.push({ r, c, delay: d * fillDelayStep });
|
||||
maxDist = Math.max(maxDist, d);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fills.forEach(({ r, c, delay }) => {
|
||||
const { inner } = elements[r][c];
|
||||
if (inner) {
|
||||
setTimeout(() => {
|
||||
this.rafAnimate(inner, "r", 0, radius, fillDur);
|
||||
}, delay);
|
||||
}
|
||||
});
|
||||
|
||||
setTimeout(
|
||||
() => {
|
||||
for (let r = 0; r < rows; r++) {
|
||||
for (let c = 0; c < cols; c++) {
|
||||
const { circle, square, inner } = elements[r][c];
|
||||
if (grid[r][c]) {
|
||||
this.rafMorphToSquare(circle, square, inner, radius, squareDur);
|
||||
} else {
|
||||
this.rafFadeOut(circle, squareDur);
|
||||
if (square) square.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
for (let r = 0; r < rows; r++) {
|
||||
for (let c = 0; c < cols; c++) {
|
||||
if (grid[r][c]) {
|
||||
this.rafShrink(
|
||||
elements[r][c].square,
|
||||
2 * radius,
|
||||
2 * radius * shrinkFactor,
|
||||
shrinkDur,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
for (let r = 0; r < rows; r++) {
|
||||
for (let c = 0; c < cols; c++) {
|
||||
if (grid[r][c]) {
|
||||
const sq = elements[r][c].square;
|
||||
const cX = parseFloat(sq.getAttribute("x"));
|
||||
const cY = parseFloat(sq.getAttribute("y"));
|
||||
const tX = cX + (centerX - cX) * moveFactor;
|
||||
const tY = cY + (centerY - cY) * moveFactor;
|
||||
this.rafMove(sq, cX, cY, tX, tY, moveDur);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
const svg = document.querySelector("#qr-container svg");
|
||||
if (svg) {
|
||||
svg.style.borderRadius = "10%";
|
||||
svg.style.border = "5px black dotted";
|
||||
}
|
||||
}, moveDur);
|
||||
}, shrinkDur);
|
||||
}, squareDur);
|
||||
},
|
||||
maxDist * fillDelayStep + fillDur,
|
||||
);
|
||||
}, maxDelayFirst + arcDur);
|
||||
},
|
||||
|
||||
rafAnimate(el, attr, from, to, dur) {
|
||||
const start = performance.now();
|
||||
const step = (now) => {
|
||||
const p = Math.min((now - start) / dur, 1);
|
||||
el.setAttribute(attr, from + (to - from) * p);
|
||||
if (p < 1) requestAnimationFrame(step);
|
||||
};
|
||||
requestAnimationFrame(step);
|
||||
},
|
||||
rafMorphToSquare(circle, square, inner, radius, dur) {
|
||||
const start = performance.now();
|
||||
const step = (now) => {
|
||||
const p = Math.min((now - start) / dur, 1);
|
||||
const r = radius * (1 - p);
|
||||
square.setAttribute("rx", r);
|
||||
square.setAttribute("ry", r);
|
||||
square.setAttribute("opacity", p);
|
||||
circle.setAttribute("opacity", 1 - p);
|
||||
if (p < 1) requestAnimationFrame(step);
|
||||
else {
|
||||
circle.remove();
|
||||
if (inner) inner.remove();
|
||||
square.removeAttribute("opacity");
|
||||
}
|
||||
};
|
||||
requestAnimationFrame(step);
|
||||
},
|
||||
rafFadeOut(el, dur) {
|
||||
const start = performance.now();
|
||||
const step = (now) => {
|
||||
const p = Math.min((now - start) / dur, 1);
|
||||
el.setAttribute("opacity", 1 - p);
|
||||
if (p < 1) requestAnimationFrame(step);
|
||||
else el.remove();
|
||||
};
|
||||
requestAnimationFrame(step);
|
||||
},
|
||||
rafShrink(el, fromS, toS, dur) {
|
||||
const start = performance.now();
|
||||
const diff = fromS - toS;
|
||||
const ox = parseFloat(el.getAttribute("x"));
|
||||
const oy = parseFloat(el.getAttribute("y"));
|
||||
const step = (now) => {
|
||||
const p = Math.min((now - start) / dur, 1);
|
||||
const cur = fromS - diff * p;
|
||||
const off = (fromS - cur) / 2;
|
||||
el.setAttribute("width", cur);
|
||||
el.setAttribute("height", cur);
|
||||
el.setAttribute("x", ox + off);
|
||||
el.setAttribute("y", oy + off);
|
||||
if (p < 1) requestAnimationFrame(step);
|
||||
};
|
||||
requestAnimationFrame(step);
|
||||
},
|
||||
rafMove(el, fx, fy, tx, ty, dur) {
|
||||
const start = performance.now();
|
||||
const step = (now) => {
|
||||
const p = Math.min((now - start) / dur, 1);
|
||||
const ease = 1 - Math.pow(1 - p, 3);
|
||||
el.setAttribute("x", fx + (tx - fx) * ease);
|
||||
el.setAttribute("y", fy + (ty - fy) * ease);
|
||||
if (p < 1) requestAnimationFrame(step);
|
||||
};
|
||||
requestAnimationFrame(step);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,159 @@
|
||||
{% extends "base.html" %} {% block title %}LiB - Настройка 2FA{% endblock %} {%
|
||||
block content %}
|
||||
<div class="flex flex-1 items-center justify-center p-4 bg-gray-100">
|
||||
<div
|
||||
class="w-full max-w-4xl bg-white rounded-lg shadow-md overflow-hidden flex flex-col md:flex-row"
|
||||
>
|
||||
<div
|
||||
class="w-full md:w-1/2 p-8 bg-gray-50 flex flex-col items-center justify-center border-b md:border-b-0 md:border-r border-gray-200"
|
||||
>
|
||||
<h2 class="text-xl font-semibold text-gray-800 mb-2">
|
||||
Настройка 2FA
|
||||
</h2>
|
||||
<p class="text-sm text-gray-500 text-center mb-6">
|
||||
Отсканируйте код в Google Authenticator
|
||||
</p>
|
||||
<div
|
||||
id="qr-container"
|
||||
class="relative flex items-center justify-center p-2 mb-4"
|
||||
style="min-height: 220px"
|
||||
>
|
||||
<div class="loader flex items-center justify-center">
|
||||
<svg
|
||||
class="animate-spin h-8 w-8 text-gray-600"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="w-full max-w-[320px] p-4 border-2 border-dashed border-gray-300 rounded-lg bg-white bg-opacity-50 text-center"
|
||||
>
|
||||
<p
|
||||
class="text-xs text-gray-500 mb-2 uppercase tracking-wide font-semibold"
|
||||
>
|
||||
Секретный ключ
|
||||
</p>
|
||||
<div
|
||||
id="secret-copy-btn"
|
||||
class="relative group cursor-pointer"
|
||||
title="Нажмите, чтобы скопировать"
|
||||
>
|
||||
<code
|
||||
id="secret-code-display"
|
||||
class="block w-full py-2 bg-gray-100 text-gray-800 rounded border border-gray-200 text-sm font-mono break-all select-all hover:bg-gray-200 transition-colors"
|
||||
>...</code
|
||||
>
|
||||
<div
|
||||
class="absolute inset-0 flex items-center justify-center bg-gray-800 bg-opacity-90 text-white text-xs rounded opacity-0 group-hover:opacity-100 transition-opacity duration-200"
|
||||
>
|
||||
Копировать
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full md:w-1/2 p-8 flex flex-col justify-center">
|
||||
<div class="max-w-xs mx-auto w-full">
|
||||
<h2
|
||||
class="text-2xl font-semibold text-gray-800 text-center mb-6"
|
||||
>
|
||||
Введите код
|
||||
</h2>
|
||||
|
||||
<form id="totp-form">
|
||||
<div
|
||||
class="flex justify-center space-x-2 sm:space-x-4 mb-6"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
maxlength="1"
|
||||
autocomplete="one-time-code"
|
||||
class="totp-digit w-10 h-14 text-center text-xl border-b-2 border-gray-300 focus:border-gray-800 focus:outline-none transition-colors caret-transparent bg-transparent"
|
||||
data-index="0"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
maxlength="1"
|
||||
autocomplete="one-time-code"
|
||||
class="totp-digit w-10 h-14 text-center text-xl border-b-2 border-gray-300 focus:border-gray-800 focus:outline-none transition-colors caret-transparent bg-transparent"
|
||||
data-index="1"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
maxlength="1"
|
||||
autocomplete="one-time-code"
|
||||
class="totp-digit w-10 h-14 text-center text-xl border-b-2 border-gray-300 focus:border-gray-800 focus:outline-none transition-colors caret-transparent bg-transparent"
|
||||
data-index="2"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
maxlength="1"
|
||||
autocomplete="one-time-code"
|
||||
class="totp-digit w-10 h-14 text-center text-xl border-b-2 border-gray-300 focus:border-gray-800 focus:outline-none transition-colors caret-transparent bg-transparent"
|
||||
data-index="3"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
maxlength="1"
|
||||
autocomplete="one-time-code"
|
||||
class="totp-digit w-10 h-14 text-center text-xl border-b-2 border-gray-300 focus:border-gray-800 focus:outline-none transition-colors caret-transparent bg-transparent"
|
||||
data-index="4"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
maxlength="1"
|
||||
autocomplete="one-time-code"
|
||||
class="totp-digit w-10 h-14 text-center text-xl border-b-2 border-gray-300 focus:border-gray-800 focus:outline-none transition-colors caret-transparent bg-transparent"
|
||||
data-index="5"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="form-message"
|
||||
class="mb-4 text-center text-sm min-h-[20px]"
|
||||
></div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
id="verify-btn"
|
||||
disabled
|
||||
class="w-full py-2 px-4 bg-gray-800 text-white rounded-md hover:bg-gray-700 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors font-medium"
|
||||
>
|
||||
Подтвердить
|
||||
</button>
|
||||
|
||||
<a
|
||||
href="/profile"
|
||||
class="block w-full text-center mt-4 text-sm text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
Отмена
|
||||
</a>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %} {% block scripts %}
|
||||
<script src="/static/2fa.js"></script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,6 @@
|
||||
def main():
|
||||
print("Hello from libraryapi!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Generated
-2261
File diff suppressed because it is too large
Load Diff
+33
-27
@@ -1,34 +1,40 @@
|
||||
[tool.poetry]
|
||||
[project]
|
||||
name = "LibraryAPI"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
description = "Это простое API для управления авторами, книгами и их жанрами."
|
||||
authors = ["wowlikon"]
|
||||
authors = [{ name = "wowlikon" }]
|
||||
readme = "README.md"
|
||||
packages = [{ include = "library_service" }]
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"toml>=0.10.2",
|
||||
"python-dotenv>=0.21.1",
|
||||
"uvicorn[standard]>=0.40.0",
|
||||
"pydantic[email]>=2.12.5",
|
||||
"fastapi[all]>=0.115.14",
|
||||
"jinja2>=3.1.6",
|
||||
"psycopg2-binary>=2.9.11",
|
||||
"alembic>=1.18.0",
|
||||
"sqlmodel>=0.0.31",
|
||||
"json-log-formatter>=1.1.1",
|
||||
"python-jose[cryptography]>=3.5.0",
|
||||
"passlib[argon2]>=1.7.4",
|
||||
"aiofiles>=25.1.0",
|
||||
"qrcode[pil]>=8.2",
|
||||
"pyotp>=2.9.0",
|
||||
]
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.12"
|
||||
fastapi = { extras = ["all"], version = "^0.115.12" }
|
||||
uvicorn = { extras = ["standard"], version = "^0.40.0" }
|
||||
psycopg2-binary = "^2.9.10"
|
||||
alembic = "^1.16.1"
|
||||
python-dotenv = "^0.21.0"
|
||||
sqlmodel = "^0.0.24"
|
||||
jinja2 = "^3.1.6"
|
||||
toml = "^0.10.2"
|
||||
python-jose = {extras = ["cryptography"], version = "^3.5.0"}
|
||||
passlib = {extras = ["argon2"], version = "^1.7.4"}
|
||||
aiofiles = "^25.1.0"
|
||||
pydantic = {extras = ["email"], version = "^2.12.5"}
|
||||
json-log-formatter = "^1.1.1"
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"black>=25.12.0",
|
||||
"isort>=7.0.0",
|
||||
"pylint>=4.0.4",
|
||||
"pytest>=8.4.2",
|
||||
"pytest-asyncio>=1.3.0",
|
||||
]
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
black = "^25.1.0"
|
||||
pytest = "^8.4.1"
|
||||
isort = "^7.0.0"
|
||||
pytest-asyncio = "^1.3.0"
|
||||
pylint = "^4.0.4"
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["library_service"]
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core"]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
Reference in New Issue
Block a user