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"
|
# 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
@@ -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
@@ -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"
|
||||||
|
|||||||
+88
-24
@@ -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:
|
||||||
@@ -82,6 +102,9 @@ def decode_token(token: str, expected_type: str = "access") -> TokenData:
|
|||||||
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}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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(),
|
||||||
})
|
}
|
||||||
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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"
|
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"
|
||||||
|
|||||||
Reference in New Issue
Block a user