Добавление страницы 2FA, poetry -> uv

This commit is contained in:
2026-01-11 15:26:39 +03:00
parent 83957ff548
commit 758e0fc9e6
14 changed files with 2654 additions and 2356 deletions
+21 -12
View File
@@ -1,14 +1,23 @@
ALGORITHM = "HS256" # Postgres
REFRESH_TOKEN_EXPIRE_DAYS = "7" POSTGRES_HOST="db"
ACCESS_TOKEN_EXPIRE_MINUTES = "20" POSTGRES_PORT="5432"
SECRET_KEY = "your-secret-key-change-in-production" POSTGRES_USER="postgres"
POSTGRES_PASSWORD="postgres"
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"
POSTGRES_HOST = "localhost" # JWT
POSTGRES_PORT = "5432" ALGORITHM="HS256"
POSTGRES_USER = "postgres" REFRESH_TOKEN_EXPIRE_DAYS="7"
POSTGRES_PASSWORD = "postgres" ACCESS_TOKEN_EXPIRE_MINUTES="15"
POSTGRES_DB = "lib" # 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
View File
@@ -3,6 +3,9 @@ FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1 ENV PYTHONUNBUFFERED=1
ENV UV_PROJECT_ENVIRONMENT="/opt/venv"
ENV PATH="/opt/venv/bin:$PATH"
WORKDIR /code WORKDIR /code
RUN apt-get update \ RUN apt-get update \
@@ -10,20 +13,21 @@ RUN apt-get update \
&& apt-get clean \ && apt-get clean \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
RUN pip install poetry RUN pip install uv
RUN poetry config virtualenvs.create false COPY ./README.md ./pyproject.toml ./uv.lock* /code/
RUN uv sync --group dev --no-install-project
COPY ./pyproject.toml ./poetry.lock* /code/
RUN poetry install --with dev --no-root --no-interaction
COPY ./library_service /code/library_service COPY ./library_service /code/library_service
COPY ./alembic.ini /code/ COPY ./alembic.ini /code/
COPY ./data.py /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 USER app
ENV PYTHONPATH=/code 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
View File
@@ -23,13 +23,15 @@ services:
build: . build: .
container_name: api container_name: api
restart: unless-stopped 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: logging:
options: options:
max-size: "10m" max-size: "10m"
max-file: "3" max-file: "3"
networks: networks:
- proxy - proxy
ports:
- 8000:8000
env_file: env_file:
- ./.env - ./.env
volumes: volumes:
@@ -42,7 +44,7 @@ services:
container_name: tests container_name: tests
build: . build: .
command: bash -c "pytest tests" command: bash -c "pytest tests"
restart: unless-stopped restart: no
logging: logging:
options: options:
max-size: "10m" max-size: "10m"
+91 -27
View File
@@ -1,25 +1,36 @@
"""Модуль авторизации и аутентификации""" """Модуль авторизации и аутентификации"""
import os import os
import base64
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 fastapi import Depends, HTTPException, status from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt from jose import jwt, JWTError, ExpiredSignatureError
from passlib.context import CryptContext from passlib.context import CryptContext
from sqlmodel import Session, select from sqlmodel import Session, select
import pyotp
import qrcode
from library_service.models.db import Role, User from library_service.models.db import Role, User
from library_service.models.dto import TokenData from library_service.models.dto import TokenData
from library_service.settings import get_session, get_logger from library_service.settings import get_session, get_logger
# Конфигурация из переменных окружения # Конфигурация JWT из переменных окружения
SECRET_KEY = os.getenv("SECRET_KEY")
ALGORITHM = os.getenv("ALGORITHM", "HS256") 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", "15"))
ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "30"))
REFRESH_TOKEN_EXPIRE_DAYS = int(os.getenv("REFRESH_TOKEN_EXPIRE_DAYS", "7")) 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() logger = get_logger()
@@ -27,8 +38,20 @@ logger = get_logger()
# OAuth2 схема # OAuth2 схема
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/token") 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: 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) 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: def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str:
"""Создает JWT access токен""" """Создает JWT access токен"""
to_encode = data.copy() delta = expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
if expires_delta: return _create_token(data, delta, "access")
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
def create_refresh_token(data: dict) -> str: def create_refresh_token(data: dict) -> str:
"""Создает JWT refresh токен""" """Создает JWT refresh токен"""
to_encode = data.copy() return _create_token(data, timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS), "refresh")
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
def decode_token(token: str, expected_type: str = "access") -> TokenData: def decode_token(token: str, expected_type: str = "access") -> TokenData:
@@ -76,14 +96,17 @@ def decode_token(token: str, expected_type: str = "access") -> TokenData:
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")
if token_type != expected_type: if token_type != expected_type:
token_error.detail=f"Invalid token type. Expected {expected_type}" token_error.detail = f"Invalid token type. Expected {expected_type}"
raise token_error raise token_error
if username is None or user_id is None: if username is None or user_id is None:
token_error.detail="Could not validate credentials" token_error.detail = "Could not validate credentials"
raise token_error raise token_error
return TokenData(username=username, user_id=user_id) return TokenData(username=username, user_id=user_id)
except ExpiredSignatureError:
token_error.detail = "Token expired"
raise token_error
except JWTError: except JWTError:
token_error.detail="Could not validate credentials" token_error.detail = "Could not validate credentials"
raise token_error raise token_error
@@ -141,6 +164,7 @@ def require_role(role_name: str):
def require_any_role(allowed_roles: list[str]): def require_any_role(allowed_roles: list[str]):
"""Создает dependency для проверки наличия хотя бы одной из ролей""" """Создает dependency для проверки наличия хотя бы одной из ролей"""
def role_checker(current_user: User = Depends(get_current_active_user)) -> User: def role_checker(current_user: User = Depends(get_current_active_user)) -> User:
user_roles = {role.name for role in current_user.roles} user_roles = {role.name for role in current_user.roles}
if not (user_roles & set(allowed_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}", detail=f"Requires one of roles: {allowed_roles}",
) )
return current_user return current_user
return role_checker return role_checker
@@ -166,7 +191,6 @@ def is_user_staff(user: User) -> bool:
return bool(roles & {"admin", "librarian"}) return bool(roles & {"admin", "librarian"})
def is_user_admin(user: User) -> bool: def is_user_admin(user: User) -> bool:
"""Проверяет, является ли пользователь администратором""" """Проверяет, является ли пользователь администратором"""
roles = {role.name for role in user.roles} roles = {role.name for role in user.roles}
@@ -207,7 +231,9 @@ def seed_admin(session: Session, admin_role: Role) -> User | None:
).all() ).all()
if existing_admins: 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 return None
admin_username = os.getenv("DEFAULT_ADMIN_USERNAME", "admin") admin_username = os.getenv("DEFAULT_ADMIN_USERNAME", "admin")
@@ -217,6 +243,7 @@ def seed_admin(session: Session, admin_role: Role) -> User | None:
generated = False generated = False
if not admin_password: if not admin_password:
import secrets import secrets
admin_password = secrets.token_urlsafe(16) admin_password = secrets.token_urlsafe(16)
generated = True generated = True
@@ -237,10 +264,10 @@ def seed_admin(session: Session, admin_role: Role) -> User | None:
logger.info(f"[+] Created admin user: {admin_username}") logger.info(f"[+] Created admin user: {admin_username}")
if generated: if generated:
logger.warning("=" * 50) logger.warning("=" * 52)
logger.warning(f"[!] GENERATED ADMIN PASSWORD: {admin_password}") logger.warning(f"[!] GENERATED ADMIN PASSWORD: {admin_password}")
logger.warning("[!] Save this password! It won't be shown again!") logger.warning("[!] Save this password! It won't be shown again!")
logger.warning("=" * 50) logger.warning("=" * 52)
return admin_user return admin_user
@@ -249,3 +276,40 @@ def run_seeds(session: Session) -> None:
"""Запускает создание ролей и администратора""" """Запускает создание ролей и администратора"""
roles = seed_roles(session) roles = seed_roles(session)
seed_admin(session, roles["admin"]) 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}
+1 -1
View File
@@ -20,7 +20,7 @@ from library_service.settings import (
get_logger, get_logger,
) )
SKIP_LOGGING_PATHS = frozenset({"/health", "/favicon.ico"}) SKIP_LOGGING_PATHS = frozenset({"/favicon.ico", "/favicon.svg"})
@asynccontextmanager @asynccontextmanager
+47 -7
View File
@@ -1,19 +1,40 @@
"""Модуль работы с авторизацией и аутентификацией пользователей""" """Модуль работы с авторизацией и аутентификацией пользователей"""
from datetime import timedelta from datetime import timedelta
from typing import Annotated 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 fastapi.security import OAuth2PasswordRequestForm
from sqlmodel import Session, select from sqlmodel import Session, select
import pyotp
from library_service.models.db import Role, User 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.settings import get_session
from library_service.auth import (ACCESS_TOKEN_EXPIRE_MINUTES, RequireAuth, RequireAdmin, RequireStaff, from library_service.auth import (
authenticate_user, get_password_hash, decode_token, ACCESS_TOKEN_EXPIRE_MINUTES,
create_access_token, create_refresh_token) 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"]) router = APIRouter(prefix="/auth", tags=["authentication"])
@@ -45,7 +66,7 @@ def register(user_data: UserCreate, session: Session = Depends(get_session)):
db_user = User( db_user = User(
**user_data.model_dump(exclude={"password"}), **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() 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], roles=[RoleRead(**role.model_dump(exclude=exclude)) for role in roles],
total=len(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}
+12 -3
View File
@@ -1,4 +1,5 @@
"""Модуль прочих эндпоинтов""" """Модуль прочих эндпоинтов"""
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Dict from typing import Dict
@@ -24,7 +25,7 @@ def get_info(app) -> Dict:
"app_info": { "app_info": {
"title": app.title, "title": app.title,
"version": app.version, "version": app.version,
"description": app.description.rsplit('|', 1)[0], "description": app.description.rsplit("|", 1)[0],
}, },
"server_time": datetime.now().isoformat(), "server_time": datetime.now().isoformat(),
} }
@@ -102,6 +103,12 @@ async def auth(request: Request):
return templates.TemplateResponse(request, "auth.html") 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) @router.get("/profile", include_in_schema=False)
async def profile(request: Request): 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) books = select(func.count()).select_from(Book)
genres = select(func.count()).select_from(Genre) genres = select(func.count()).select_from(Genre)
users = select(func.count()).select_from(User) users = select(func.count()).select_from(User)
return JSONResponse(content={ return JSONResponse(
content={
"authors": session.exec(authors).one(), "authors": session.exec(authors).one(),
"books": session.exec(books).one(), "books": session.exec(books).one(),
"genres": session.exec(genres).one(), "genres": session.exec(genres).one(),
"users": session.exec(users).one(), "users": session.exec(users).one(),
}) }
)
+5 -4
View File
@@ -1,4 +1,5 @@
"""Модуль настроек проекта""" """Модуль настроек проекта"""
import os, logging import os, logging
from pathlib import Path from pathlib import Path
@@ -65,11 +66,11 @@ OPENAPI_TAGS = [
def get_app(lifespan=None, /) -> FastAPI: def get_app(lifespan=None, /) -> FastAPI:
"""Возвращает экземпляр FastAPI приложения""" """Возвращает экземпляр FastAPI приложения"""
poetry_cfg = _pyproject["tool"]["poetry"] project_cfg = _pyproject["project"]
return FastAPI( return FastAPI(
title=poetry_cfg["name"], title=project_cfg["name"],
description=f"{poetry_cfg['description']} | [Вернуться на главную](/)", description=f"{project_cfg['description']} | [Вернуться на главную](/)",
version=poetry_cfg["version"], version=project_cfg["version"],
lifespan=lifespan, lifespan=lifespan,
openapi_tags=OPENAPI_TAGS, openapi_tags=OPENAPI_TAGS,
) )
+461
View File
@@ -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);
},
};
+159
View File
@@ -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 %}
+6
View File
@@ -0,0 +1,6 @@
def main():
print("Hello from libraryapi!")
if __name__ == "__main__":
main()
Generated
-2261
View File
File diff suppressed because it is too large Load Diff
+33 -27
View File
@@ -1,34 +1,40 @@
[tool.poetry] [project]
name = "LibraryAPI" name = "LibraryAPI"
version = "0.3.0" version = "0.4.0"
description = "Это простое API для управления авторами, книгами и их жанрами." description = "Это простое API для управления авторами, книгами и их жанрами."
authors = ["wowlikon"] authors = [{ name = "wowlikon" }]
readme = "README.md" 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] [dependency-groups]
python = "^3.12" dev = [
fastapi = { extras = ["all"], version = "^0.115.12" } "black>=25.12.0",
uvicorn = { extras = ["standard"], version = "^0.40.0" } "isort>=7.0.0",
psycopg2-binary = "^2.9.10" "pylint>=4.0.4",
alembic = "^1.16.1" "pytest>=8.4.2",
python-dotenv = "^0.21.0" "pytest-asyncio>=1.3.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"
[tool.poetry.group.dev.dependencies] [tool.hatch.build.targets.wheel]
black = "^25.1.0" packages = ["library_service"]
pytest = "^8.4.1"
isort = "^7.0.0"
pytest-asyncio = "^1.3.0"
pylint = "^4.0.4"
[build-system] [build-system]
requires = ["poetry-core"] requires = ["hatchling"]
build-backend = "poetry.core.masonry.api" build-backend = "hatchling.build"
Generated
+1798
View File
File diff suppressed because it is too large Load Diff