diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..17fc03c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,3 @@ + +*.log +__pycache__/ diff --git a/.env b/.env index afb9aac..824cbba 100644 --- a/.env +++ b/.env @@ -1,10 +1,11 @@ # Postgres -POSTGRES_HOST="db" +POSTGRES_HOST="localhost" POSTGRES_PORT="5432" POSTGRES_USER="postgres" POSTGRES_PASSWORD="postgres" POSTGRES_DB="lib" +# Default admin account # DEFAULT_ADMIN_USERNAME="admin" # DEFAULT_ADMIN_EMAIL="admin@example.com" # DEFAULT_ADMIN_PASSWORD="password-is-generated-randomly-on-first-launch" @@ -13,7 +14,8 @@ POSTGRES_DB="lib" ALGORITHM="HS256" REFRESH_TOKEN_EXPIRE_DAYS="7" ACCESS_TOKEN_EXPIRE_MINUTES="15" -# SECRET_KEY="your-secret-key-change-in-production" +PARTIAL_TOKEN_EXPIRE_MINUTES="5" +SECRET_KEY="your-secret-key-change-in-production" # Hash ARGON2_TYPE="id" @@ -21,3 +23,15 @@ ARGON2_TIME_COST="3" ARGON2_MEMORY_COST="65536" ARGON2_PARALLELISM="4" ARGON2_SALT_LENGTH="16" +ARGON2_HASH_LENGTH="48" + +# Recovery codes +RECOVERY_CODES_COUNT="10" +RECOVERY_CODE_SEGMENTS="4" +RECOVERY_CODE_SEGMENT_BYTES="2" +RECOVERY_MIN_REMAINING_WARNING="3" +RECOVERY_MAX_AGE_DAYS="365" + +# TOTP_2FA +TOTP_ISSUER="LiB" +TOTP_VALID_WINDOW="1" diff --git a/library_service/auth/__init__.py b/library_service/auth/__init__.py new file mode 100644 index 0000000..ee52174 --- /dev/null +++ b/library_service/auth/__init__.py @@ -0,0 +1,109 @@ +"""Пакет авторизации и аутентификации""" + +from .core import ( + SECRET_KEY, + ALGORITHM, + PARTIAL_TOKEN_EXPIRE_MINUTES, + ACCESS_TOKEN_EXPIRE_MINUTES, + REFRESH_TOKEN_EXPIRE_DAYS, + ARGON2_TIME_COST, + ARGON2_MEMORY_COST, + ARGON2_PARALLELISM, + ARGON2_SALT_LENGTH, + ARGON2_HASH_LENGTH, + RECOVERY_CODES_COUNT, + RECOVERY_CODE_SEGMENTS, + RECOVERY_CODE_SEGMENT_BYTES, + RECOVERY_MIN_REMAINING_WARNING, + RECOVERY_MAX_AGE_DAYS, + verify_password, + get_password_hash, + create_access_token, + create_refresh_token, + create_partial_token, + decode_token, + authenticate_user, + get_current_user, + get_current_active_user, + get_user_from_partial_token, + require_role, + require_any_role, + is_user_staff, + is_user_admin, + RequireAuth, + RequireAdmin, + RequireMember, + RequireLibrarian, + RequirePartialAuth, + RequireStaff, +) + +from .seed import ( + seed_roles, + seed_admin, + run_seeds, +) + +from .recovery import ( + generate_codes_for_user, + verify_and_use_code, + get_codes_status, +) + +from .totp import ( + generate_secret, + get_provisioning_uri, + verify_totp_code, + qr_to_bitmap_b64, + generate_totp_setup, + TOTP_ISSUER, + TOTP_VALID_WINDOW, +) + +__all__ = [ + "SECRET_KEY", + "ALGORITHM", + "ACCESS_TOKEN_EXPIRE_MINUTES", + "REFRESH_TOKEN_EXPIRE_DAYS", + "ARGON2_TIME_COST", + "ARGON2_MEMORY_COST", + "ARGON2_PARALLELISM", + "ARGON2_SALT_LENGTH", + "ARGON2_HASH_LENGTH", + "RECOVERY_CODES_COUNT", + "RECOVERY_CODE_SEGMENTS", + "RECOVERY_CODE_SEGMENT_BYTES", + "RECOVERY_MIN_REMAINING_WARNING", + "RECOVERY_MAX_AGE_DAYS", + "verify_password", + "get_password_hash", + "create_access_token", + "create_refresh_token", + "decode_token", + "authenticate_user", + "get_current_user", + "get_current_active_user", + "require_role", + "require_any_role", + "is_user_staff", + "is_user_admin", + "RequireAuth", + "RequireAdmin", + "RequireMember", + "RequireLibrarian", + "RequireStaff", + "seed_roles", + "seed_admin", + "run_seeds", + "generate_secre", + "get_provisioning_uri", + "verify_totp_code", + "qr_to_bitmap_b64", + "generate_totp_setup," "generate_codes_for_user", + "verify_and_use_code", + "get_codes_status", + "CODES_COUNT", + "MIN_REMAINING_WARNING", + "TOTP_ISSUER", + "TOTP_VALID_WINDOW", +] diff --git a/library_service/auth.py b/library_service/auth/core.py similarity index 61% rename from library_service/auth.py rename to library_service/auth/core.py index d387c0f..ee0b691 100644 --- a/library_service/auth.py +++ b/library_service/auth/core.py @@ -1,7 +1,6 @@ -"""Модуль авторизации и аутентификации""" +"""Модуль основного функционала авторизации и аутентификации""" import os -import base64 from datetime import datetime, timedelta, timezone from typing import Annotated @@ -11,8 +10,6 @@ from fastapi.security import OAuth2PasswordBearer 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 @@ -22,17 +19,26 @@ from library_service.settings import get_session, get_logger # Конфигурация JWT из переменных окружения SECRET_KEY = os.getenv("SECRET_KEY") ALGORITHM = os.getenv("ALGORITHM", "HS256") +PARTIAL_TOKEN_EXPIRE_MINUTES = int(os.getenv("PARTIAL_TOKEN_EXPIRE_MINUTES", "5")) 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_MEMORY_COST = int(os.getenv("ARGON2_MEMORY_COST", "131072")) +ARGON2_PARALLELISM = int(os.getenv("ARGON2_PARALLELISM", "2")) ARGON2_SALT_LENGTH = int(os.getenv("ARGON2_SALT_LENGTH", "16")) +ARGON2_HASH_LENGTH = int(os.getenv("ARGON2_HASH_LENGTH", "48")) -# Получение логгера +# Конфигурация кодов восстановления +RECOVERY_CODES_COUNT = int(os.getenv("RECOVERY_CODES_COUNT", "10")) +RECOVERY_CODE_SEGMENTS = int(os.getenv("RECOVERY_CODE_SEGMENTS", "4")) +RECOVERY_CODE_SEGMENT_BYTES = int(os.getenv("RECOVERY_CODE_SEGMENT_BYTES", "2")) +RECOVERY_MIN_REMAINING_WARNING = int(os.getenv("RECOVERY_MIN_REMAINING_WARNING", "3")) +RECOVERY_MAX_AGE_DAYS = int(os.getenv("RECOVERY_MAX_AGE_DAYS", "365")) + +# Получение логгера logger = get_logger() # OAuth2 схема @@ -51,6 +57,7 @@ pwd_context = CryptContext( argon2__memory_cost=ARGON2_MEMORY_COST, argon2__parallelism=ARGON2_PARALLELISM, argon2__salt_len=ARGON2_SALT_LENGTH, + argon2__hash_len=ARGON2_HASH_LENGTH, ) @@ -64,15 +71,32 @@ def get_password_hash(password: str) -> str: return pwd_context.hash(password) -def _create_token(data: dict, expires_delta: timedelta, token_type: str) -> str: +def _create_token( + data: dict, + expires_delta: timedelta, + token_type: str, + is_partial: bool = False, +) -> str: """Базовая функция создания токена""" now = datetime.now(timezone.utc) - to_encode = {**data, "iat": now, "exp": now + expires_delta, "type": token_type} + to_encode = { + **data, + "iat": now, + "exp": now + expires_delta, + "type": token_type, + "partial": is_partial, + } if token_type == "refresh": to_encode.update({"jti": str(uuid4())}) return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) +def create_partial_token(data: dict) -> str: + """Создает partial токен для незавершённой 2FA аутентификации""" + delta = timedelta(minutes=PARTIAL_TOKEN_EXPIRE_MINUTES) + return _create_token(data, delta, "partial", is_partial=True) + + def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str: """Создает JWT access токен""" delta = expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) @@ -84,7 +108,11 @@ def create_refresh_token(data: dict) -> str: return _create_token(data, timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS), "refresh") -def decode_token(token: str, expected_type: str = "access") -> TokenData: +def decode_token( + token: str, + expected_type: str = "access", + allow_partial: bool = False, +) -> TokenData: """Декодирует и проверяет JWT токен""" token_error = HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -95,13 +123,21 @@ def decode_token(token: str, expected_type: str = "access") -> TokenData: username: str | None = payload.get("sub") user_id: int | None = payload.get("user_id") token_type: str | None = payload.get("type") - if token_type != expected_type: + is_partial: bool = payload.get("partial", False) + + if token_type == "partial": + if not allow_partial: + token_error.detail = "2FA verification required" + raise token_error + elif token_type != expected_type: token_error.detail = f"Invalid token type. Expected {expected_type}" raise token_error + if username is None or user_id is None: token_error.detail = "Could not validate credentials" raise token_error - return TokenData(username=username, user_id=user_id) + + return TokenData(username=username, user_id=user_id, is_partial=is_partial) except ExpiredSignatureError: token_error.detail = "Token expired" raise token_error @@ -147,6 +183,29 @@ def get_current_active_user( return current_user +def get_user_from_partial_token( + token: Annotated[str, Depends(oauth2_scheme)], + session: Session = Depends(get_session), +) -> User: + """Возвращает пользователя из partial токена (для 2FA верификации)""" + token_data = decode_token(token, expected_type="access", allow_partial=True) + + if not token_data.is_partial: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Full token provided, 2FA not required", + ) + + user = session.get(User, token_data.user_id) + if user is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User not found", + headers={"WWW-Authenticate": "Bearer"}, + ) + return user + + def require_role(role_name: str): """Создает dependency для проверки наличия определенной роли""" @@ -182,6 +241,7 @@ RequireAuth = Annotated[User, Depends(get_current_active_user)] RequireAdmin = Annotated[User, Depends(require_role("admin"))] RequireMember = Annotated[User, Depends(require_role("member"))] RequireLibrarian = Annotated[User, Depends(require_role("librarian"))] +RequirePartialAuth = Annotated[User, Depends(get_user_from_partial_token)] RequireStaff = Annotated[User, Depends(require_any_role(["admin", "librarian"]))] @@ -195,121 +255,3 @@ def is_user_admin(user: User) -> bool: """Проверяет, является ли пользователь администратором""" roles = {role.name for role in user.roles} return "admin" in roles - - -def seed_roles(session: Session) -> dict[str, Role]: - """Создает роли по умолчанию, если их нет""" - default_roles = [ - {"name": "admin", "description": "Администратор системы", "payroll": 80000}, - {"name": "librarian", "description": "Библиотекарь", "payroll": 55000}, - {"name": "member", "description": "Посетитель библиотеки", "payroll": 0}, - ] - - roles = {} - for role_data in default_roles: - existing = session.exec( - select(Role).where(Role.name == role_data["name"]) - ).first() - - if existing: - roles[role_data["name"]] = existing - else: - role = Role(**role_data) - session.add(role) - session.commit() - session.refresh(role) - roles[role_data["name"]] = role - logger.info(f"[+] Created role: {role_data['name']}") - - return roles - - -def seed_admin(session: Session, admin_role: Role) -> User | None: - """Создает администратора по умолчанию, если нет ни одного""" - existing_admins = session.exec( - select(User).join(User.roles).where(Role.name == "admin") - ).all() - - if existing_admins: - logger.info( - f"[=] Admin already exists: {existing_admins[0].username}, skipping creation" - ) - return None - - admin_username = os.getenv("DEFAULT_ADMIN_USERNAME", "admin") - admin_email = os.getenv("DEFAULT_ADMIN_EMAIL", "admin@example.com") - admin_password = os.getenv("DEFAULT_ADMIN_PASSWORD") - - generated = False - if not admin_password: - import secrets - - admin_password = secrets.token_urlsafe(16) - generated = True - - admin_user = User( - username=admin_username, - email=admin_email, - full_name="Системный администратор", - hashed_password=get_password_hash(admin_password), - is_active=True, - is_verified=True, - ) - admin_user.roles.append(admin_role) - - session.add(admin_user) - session.commit() - session.refresh(admin_user) - - logger.info(f"[+] Created admin user: {admin_username}") - - if generated: - logger.warning("=" * 52) - logger.warning(f"[!] GENERATED ADMIN PASSWORD: {admin_password}") - logger.warning("[!] Save this password! It won't be shown again!") - logger.warning("=" * 52) - - return admin_user - - -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} diff --git a/library_service/auth/recovery.py b/library_service/auth/recovery.py new file mode 100644 index 0000000..e9b7ef3 --- /dev/null +++ b/library_service/auth/recovery.py @@ -0,0 +1,149 @@ +"""Модуль резервных кодов восстановления пароля""" + +import os +import secrets +from datetime import datetime, timezone, timedelta + +import argon2 +from sqlmodel import Session + +from .core import ( + ARGON2_TIME_COST, + ARGON2_MEMORY_COST, + ARGON2_PARALLELISM, + ARGON2_SALT_LENGTH, + ARGON2_HASH_LENGTH, + RECOVERY_CODES_COUNT, + RECOVERY_CODE_SEGMENTS, + RECOVERY_CODE_SEGMENT_BYTES, + RECOVERY_MIN_REMAINING_WARNING, + RECOVERY_MAX_AGE_DAYS, +) +from library_service.settings import get_logger + +logger = get_logger() + + +# Argon2 для кодов +_recovery_hasher = argon2.PasswordHasher( + type=argon2.Type.ID, + time_cost=ARGON2_TIME_COST, + hash_len=ARGON2_HASH_LENGTH, + salt_len=ARGON2_SALT_LENGTH, + memory_cost=ARGON2_MEMORY_COST, + parallelism=ARGON2_PARALLELISM, +) + + +def generate_code() -> str: + """Генерация кода в формате xxxx-xxxx-xxxx-xxxx""" + segments = [ + secrets.token_hex(RECOVERY_CODE_SEGMENT_BYTES) + for _ in range(RECOVERY_CODE_SEGMENTS) + ] + return "-".join(segments) + + +def normalize_code(code: str) -> str: + """Нормализация: убираем дефисы, lowercase""" + return code.replace("-", "").lower().strip() + + +def hash_code(code: str) -> str: + """Хеширование кода""" + return _recovery_hasher.hash(normalize_code(code)) + + +def verify_code(plain_code: str, hashed: str) -> bool: + """Проверка кода""" + if not hashed: + return False + try: + _recovery_hasher.verify(hashed, normalize_code(plain_code)) + return True + except argon2.exceptions.VerifyMismatchError: + return False + except argon2.exceptions.InvalidHashError: + logger.warning("Invalid recovery code hash format") + return False + + +def generate_codes_for_user(session: Session, user) -> list[str]: + """Генерация новых резервных кодов для пользователя.""" + plain_codes: list[str] = [] + hashed_codes: list[str] = [] + + for _ in range(RECOVERY_CODES_COUNT): + code = generate_code() + plain_codes.append(code) + hashed_codes.append(hash_code(code)) + + user.recovery_code_hashes = " ".join(hashed_codes) + user.recovery_codes_generated_at = datetime.now(timezone.utc) + + session.add(user) + session.commit() + session.refresh(user) + + logger.info(f"Generated {RECOVERY_CODES_COUNT} recovery codes for user {user.id}") + + return plain_codes + + +def verify_and_use_code(session: Session, user, code: str) -> bool: + """Проверка и использование кода. При успехе хеш заменяется на пустую строку""" + if not user.recovery_code_hashes: + return False + + hashes = user.recovery_code_hashes.split(" ") + + for i, stored_hash in enumerate(hashes): + if stored_hash and verify_code(code, stored_hash): + hashes[i] = "" + user.recovery_code_hashes = " ".join(hashes) + + session.add(user) + session.commit() + + logger.info( + f"Recovery code #{i + 1} used for user {user.id}, " + f"remaining: {sum(1 for h in hashes if h)}" + ) + return True + + logger.warning(f"Invalid recovery code attempt for user {user.id}") + return False + + +def get_codes_status(user) -> dict: + """Статус резервных кодов""" + if not user.recovery_code_hashes: + return { + "total": 0, + "remaining": 0, + "used_codes": [], + "generated_at": None, + "should_regenerate": True, + } + + hashes = user.recovery_code_hashes.split(" ") + used_codes = [h == "" for h in hashes] + remaining = sum(1 for h in hashes if h) + total = len(hashes) + generated_at = user.recovery_codes_generated_at + + should_regenerate = remaining <= RECOVERY_MIN_REMAINING_WARNING + + if generated_at: + generated_at = generated_at.replace(tzinfo=timezone.utc) + age = datetime.now(timezone.utc) - generated_at + if age > timedelta(days=RECOVERY_MAX_AGE_DAYS): + should_regenerate = True + + return { + "total": total, + "remaining": remaining, + "used_codes": used_codes, + "generated_at": generated_at, + "should_regenerate": should_regenerate, + } diff --git a/library_service/auth/seed.py b/library_service/auth/seed.py new file mode 100644 index 0000000..297a519 --- /dev/null +++ b/library_service/auth/seed.py @@ -0,0 +1,95 @@ +"""Модуль создания начальных ролей и администратора""" + +import os + +from sqlmodel import Session, select +from library_service.models.db import Role, User + +from .core import get_password_hash +from library_service.settings import get_session, get_logger + +# Получение логгера +logger = get_logger() + + +def seed_roles(session: Session) -> dict[str, Role]: + """Создает роли по умолчанию, если их нет""" + default_roles = [ + {"name": "admin", "description": "Администратор системы", "payroll": 80000}, + {"name": "librarian", "description": "Библиотекарь", "payroll": 55000}, + {"name": "member", "description": "Посетитель библиотеки", "payroll": 0}, + ] + + roles = {} + for role_data in default_roles: + existing = session.exec( + select(Role).where(Role.name == role_data["name"]) + ).first() + + if existing: + roles[role_data["name"]] = existing + else: + role = Role(**role_data) + session.add(role) + session.commit() + session.refresh(role) + roles[role_data["name"]] = role + logger.info(f"[+] Created role: {role_data['name']}") + + return roles + + +def seed_admin(session: Session, admin_role: Role) -> User | None: + """Создает администратора по умолчанию, если нет ни одного""" + existing_admins = session.exec( + select(User) + .join(User.roles) # ty: ignore[invalid-argument-type] + .where(Role.name == "admin") + ).all() + + if existing_admins: + logger.info( + f"[=] Admin already exists: {existing_admins[0].username}, skipping creation" + ) + return None + + admin_username = os.getenv("DEFAULT_ADMIN_USERNAME", "admin") + admin_email = os.getenv("DEFAULT_ADMIN_EMAIL", "admin@example.com") + admin_password = os.getenv("DEFAULT_ADMIN_PASSWORD") + + generated = False + if not admin_password: + import secrets + + admin_password = secrets.token_urlsafe(16) + generated = True + + admin_user = User( + username=admin_username, + email=admin_email, + full_name="Системный администратор", + hashed_password=get_password_hash(admin_password), + is_active=True, + is_verified=True, + ) + admin_user.roles.append(admin_role) + + session.add(admin_user) + session.commit() + session.refresh(admin_user) + + logger.info(f"[+] Created admin user: {admin_username}") + + if generated: + logger.warning("=" * 52) + logger.warning(f"[!] GENERATED ADMIN PASSWORD: {admin_password}") + logger.warning("[!] Save this password! It won't be shown again!") + logger.warning("=" * 52) + + return admin_user + + +def run_seeds(session: Session) -> None: + """Запускает создание ролей и администратора""" + roles = seed_roles(session) + seed_admin(session, roles["admin"]) diff --git a/library_service/auth/totp.py b/library_service/auth/totp.py new file mode 100644 index 0000000..9a19aa2 --- /dev/null +++ b/library_service/auth/totp.py @@ -0,0 +1,78 @@ +"""Модуль TOTP 2FA""" + +import base64 +import os + +import pyotp +import qrcode + + +# Настройкт из переменных окружения +TOTP_ISSUER = os.getenv("TOTP_ISSUER", "LiB") +TOTP_VALID_WINDOW = int(os.getenv("TOTP_VALID_WINDOW", "1")) + + +def generate_secret() -> str: + """Генерация нового TOTP секрета""" + return pyotp.random_base32() + + +def get_provisioning_uri(secret: str, username: str) -> str: + """Получение URI для QR-кода""" + totp = pyotp.TOTP(secret) + return totp.provisioning_uri(name=username, issuer_name=TOTP_ISSUER) + + +def verify_totp_code(secret: str, code: str) -> bool: + """Проверка TOTP кода""" + if not secret or not code: + return False + totp = pyotp.TOTP(secret) + return totp.verify(code, valid_window=TOTP_VALID_WINDOW) + + +def qr_to_bitmap_b64(data: str) -> dict: + """Конвертирует данные в QR-код и возвращает как base64 bitmap""" + 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} + + +def generate_totp_setup(username: str) -> dict: + """Генерация данных для настройки TOTP""" + secret = generate_secret() + uri = get_provisioning_uri(secret, username) + bitmap_data = qr_to_bitmap_b64(uri) + + return { + "secret": secret, + "username": username, + "issuer": TOTP_ISSUER, + **bitmap_data, + } diff --git a/library_service/models/db/book.py b/library_service/models/db/book.py index a97d617..06ea925 100644 --- a/library_service/models/db/book.py +++ b/library_service/models/db/book.py @@ -1,4 +1,5 @@ """Модуль DB-моделей книг""" + from typing import TYPE_CHECKING, List from sqlalchemy import Column, String @@ -15,10 +16,11 @@ if TYPE_CHECKING: class Book(BookBase, table=True): """Модель книги в базе данных""" + id: int | None = Field(default=None, primary_key=True, index=True) status: BookStatus = Field( default=BookStatus.ACTIVE, - sa_column=Column(String, nullable=False, default="active") + sa_column=Column(String, nullable=False, default="active"), ) authors: List["Author"] = Relationship( back_populates="books", link_model=AuthorBookLink @@ -26,4 +28,6 @@ class Book(BookBase, table=True): genres: List["Genre"] = Relationship( back_populates="books", link_model=GenreBookLink ) - loans: List["BookUserLink"] = Relationship(sa_relationship_kwargs={"cascade": "all, delete"}) + loans: List["BookUserLink"] = Relationship( # ty: ignore[unresolved-reference] + sa_relationship_kwargs={"cascade": "all, delete"} + ) diff --git a/library_service/models/db/links.py b/library_service/models/db/links.py index a706035..683d8cd 100644 --- a/library_service/models/db/links.py +++ b/library_service/models/db/links.py @@ -1,10 +1,12 @@ """Модуль связей между сущностями в БД""" -from datetime import datetime + +from datetime import datetime, timezone from sqlmodel import SQLModel, Field class AuthorBookLink(SQLModel, table=True): """Модель связи автора и книги""" + author_id: int | None = Field( default=None, foreign_key="author.id", primary_key=True ) @@ -13,12 +15,14 @@ class AuthorBookLink(SQLModel, table=True): class GenreBookLink(SQLModel, table=True): """Модель связи жанра и книги""" + genre_id: int | None = Field(default=None, foreign_key="genre.id", primary_key=True) book_id: int | None = Field(default=None, foreign_key="book.id", primary_key=True) class UserRoleLink(SQLModel, table=True): """Модель связи роли и пользователя""" + __tablename__ = "user_roles" user_id: int | None = Field(default=None, foreign_key="users.id", primary_key=True) @@ -30,13 +34,14 @@ class BookUserLink(SQLModel, table=True): Модель истории выдачи книг (Loan). Связывает книгу и пользователя с фиксацией времени. """ + __tablename__ = "book_loans" id: int | None = Field(default=None, primary_key=True, index=True) - + book_id: int = Field(foreign_key="book.id") user_id: int = Field(foreign_key="users.id") - - borrowed_at: datetime = Field(default_factory=datetime.utcnow) + + borrowed_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) due_date: datetime - returned_at: datetime | None = Field(default=None) \ No newline at end of file + returned_at: datetime | None = Field(default=None) diff --git a/library_service/models/db/user.py b/library_service/models/db/user.py index cb14b00..612a1e2 100644 --- a/library_service/models/db/user.py +++ b/library_service/models/db/user.py @@ -1,5 +1,6 @@ """Модуль DB-моделей пользователей""" -from datetime import datetime + +from datetime import datetime, timezone from typing import TYPE_CHECKING, List from sqlmodel import Field, Relationship @@ -13,17 +14,58 @@ if TYPE_CHECKING: class User(UserBase, table=True): """Модель пользователя в базе данных""" + __tablename__ = "users" id: int | None = Field(default=None, primary_key=True, index=True) hashed_password: str = Field(nullable=False) + is_2fa_enabled: bool = Field(default=False) + totp_secret: str | None = Field(default=None, max_length=64) + recovery_code_hashes: str | None = Field(default=None, max_length=1500) + recovery_codes_generated_at: datetime | None = Field(default=None) is_active: bool = Field(default=True) is_verified: bool = Field(default=False) - created_at: datetime = Field(default_factory=datetime.utcnow) + created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) updated_at: datetime | None = Field( - default=None, sa_column_kwargs={"onupdate": datetime.utcnow} + default=None, sa_column_kwargs={"onupdate": lambda: datetime.now(timezone.utc)} ) - # Связи roles: List["Role"] = Relationship(back_populates="users", link_model=UserRoleLink) - loans: List["BookUserLink"] = Relationship(sa_relationship_kwargs={"cascade": "all, delete"}) + loans: List["BookUserLink"] = Relationship( # ty: ignore[unresolved-reference] + sa_relationship_kwargs={"cascade": "all, delete"} + ) + + @property + def recovery_codes_list(self) -> list[str]: + """Список хешей""" + if not self.recovery_code_hashes: + return [] + return self.recovery_code_hashes.split(" ") + + @property + def recovery_codes_total(self) -> int: + """Общее количество слотов""" + if not self.recovery_code_hashes: + return 0 + return len(self.recovery_codes_list) + + @property + def recovery_codes_remaining(self) -> int: + """Количество неиспользованных кодов""" + return sum(1 for h in self.recovery_codes_list if h) + + @property + def recovery_codes_used(self) -> int: + """Количество использованных кодов""" + return self.recovery_codes_total - self.recovery_codes_remaining + + def get_recovery_code_positions(self) -> dict[str, list[int]]: + """Возвращает позиции использованных и оставшихся кодов""" + used = [] + remaining = [] + for i, h in enumerate(self.recovery_codes_list, start=1): + if h: + remaining.append(i) + else: + used.append(i) + return {"used": used, "remaining": remaining} diff --git a/library_service/models/dto/__init__.py b/library_service/models/dto/__init__.py index ded0399..133128d 100644 --- a/library_service/models/dto/__init__.py +++ b/library_service/models/dto/__init__.py @@ -1,13 +1,29 @@ """Модуль DTO-моделей""" + from .author import AuthorBase, AuthorCreate, AuthorList, AuthorRead, AuthorUpdate from .genre import GenreBase, GenreCreate, GenreList, GenreRead, GenreUpdate from .book import BookBase, BookCreate, BookList, BookRead, BookUpdate from .role import RoleBase, RoleCreate, RoleList, RoleRead, RoleUpdate from .user import UserBase, UserCreate, UserList, UserRead, UserUpdate, UserLogin from .loan import LoanBase, LoanCreate, LoanList, LoanRead, LoanUpdate -from .token import Token, TokenData -from .combined import (AuthorWithBooks, GenreWithBooks, BookWithAuthors, BookWithGenres, - BookWithAuthorsAndGenres, BookFilteredList, BookStatusUpdate, LoanWithBook) +from .recovery import RecoveryCodesResponse, RecoveryCodesStatus, RecoveryCodeUse +from .token import Token, TokenData, PartialToken +from .combined import ( + AuthorWithBooks, + GenreWithBooks, + BookWithAuthors, + BookWithGenres, + BookWithAuthorsAndGenres, + BookFilteredList, + BookStatusUpdate, + LoanWithBook, + LoginResponse, + RegisterResponse, + TOTPSetupResponse, + TOTPVerifyRequest, + TOTPDisableRequest, + PasswordResetResponse, +) __all__ = [ "AuthorBase", @@ -46,4 +62,14 @@ __all__ = [ "RoleList", "Token", "TokenData", + "PartialToken", + "TOTPSetupResponse", + "TOTPVerifyRequest", + "TOTPDisableRequest", + "RecoveryCodeUse", + "LoginResponse", + "RegisterResponse", + "RecoveryCodesStatus", + "PasswordResetResponse", + "RecoveryCodesResponse", ] diff --git a/library_service/models/dto/combined.py b/library_service/models/dto/combined.py index ed3c3af..e91a743 100644 --- a/library_service/models/dto/combined.py +++ b/library_service/models/dto/combined.py @@ -1,5 +1,8 @@ """Модуль объединёных объектов""" + +from datetime import datetime from typing import List + from sqlmodel import SQLModel, Field from .author import AuthorRead @@ -8,8 +11,13 @@ from .book import BookRead from .loan import LoanRead from ..enums import BookStatus +from .user import UserRead +from .recovery import RecoveryCodesResponse, RecoveryCodesStatus + + class AuthorWithBooks(SQLModel): """Модель автора с книгами""" + id: int name: str books: List[BookRead] = Field(default_factory=list) @@ -17,6 +25,7 @@ class AuthorWithBooks(SQLModel): class GenreWithBooks(SQLModel): """Модель жанра с книгами""" + id: int name: str books: List[BookRead] = Field(default_factory=list) @@ -24,6 +33,7 @@ class GenreWithBooks(SQLModel): class BookWithAuthors(SQLModel): """Модель книги с авторами""" + id: int title: str description: str @@ -32,6 +42,7 @@ class BookWithAuthors(SQLModel): class BookWithGenres(SQLModel): """Модель книги с жанрами""" + id: int title: str description: str @@ -41,6 +52,7 @@ class BookWithGenres(SQLModel): class BookWithAuthorsAndGenres(SQLModel): """Модель с авторами и жанрами""" + id: int title: str description: str @@ -51,13 +63,68 @@ class BookWithAuthorsAndGenres(SQLModel): class BookFilteredList(SQLModel): """Список книг с фильтрацией""" + books: List[BookWithAuthorsAndGenres] total: int + class LoanWithBook(LoanRead): """Модель выдачи, включающая данные о книге""" + book: BookRead + class BookStatusUpdate(SQLModel): """Модель для ручного изменения статуса библиотекарем""" + status: str + + +class LoginResponse(SQLModel): + """Модель для авторизации пользователя""" + + access_token: str | None = None + partial_token: str | None = None + refresh_token: str | None = None + token_type: str = "bearer" + requires_2fa: bool = False + + +class RegisterResponse(SQLModel): + """Модель для регистрации пользователя""" + + user: UserRead + recovery_codes: RecoveryCodesResponse + + +class PasswordResetResponse(SQLModel): + """Модель для сброса пароля""" + + total: int + remaining: int + used_codes: list[bool] + generated_at: datetime | None + should_regenerate: bool + + +class TOTPSetupResponse(SQLModel): + """Модель для генерации данных для настройки TOTP""" + + secret: str + username: str + issuer: str + size: int + padding: int + bitmap_b64: str + + +class TOTPVerifyRequest(SQLModel): + """Модель для проверки TOTP кода""" + + code: str = Field(min_length=6, max_length=6, regex=r"^\d{6}$") + + +class TOTPDisableRequest(SQLModel): + """Модель для отключения TOTP 2FA""" + + password: str diff --git a/library_service/models/dto/recovery.py b/library_service/models/dto/recovery.py new file mode 100644 index 0000000..f9f2397 --- /dev/null +++ b/library_service/models/dto/recovery.py @@ -0,0 +1,52 @@ +"""Модуль DTO-моделей для резервных кодов восстановления""" + +from datetime import datetime +import re + +from pydantic import field_validator +from sqlmodel import SQLModel, Field + + +class RecoveryCodesResponse(SQLModel): + """Ответ при генерации резервных кодов""" + + codes: list[str] + generated_at: datetime + + +class RecoveryCodesStatus(SQLModel): + """Статус резервных кодов пользователя""" + + total: int + remaining: int + used_codes: list[bool] + generated_at: datetime | None + should_regenerate: bool + + +class RecoveryCodeUse(SQLModel): + """Запрос на сброс пароля через резервный код""" + + username: str + recovery_code: str = Field(min_length=19, max_length=19) + new_password: str = Field(min_length=8, max_length=100) + + @field_validator("recovery_code") + @classmethod + def validate_recovery_code(cls, v: str) -> str: + if not re.match( + r"^[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}$", v + ): + raise ValueError("Invalid recovery code format") + return v.lower() + + @field_validator("new_password") + @classmethod + def validate_password(cls, v: str) -> str: + if not re.search(r"[A-Z]", v): + raise ValueError("Password must contain uppercase") + if not re.search(r"[a-z]", v): + raise ValueError("Password must contain lowercase") + if not re.search(r"\d", v): + raise ValueError("Password must contain digit") + return v diff --git a/library_service/models/dto/token.py b/library_service/models/dto/token.py index 903bc93..fe93edf 100644 --- a/library_service/models/dto/token.py +++ b/library_service/models/dto/token.py @@ -1,15 +1,27 @@ """Модуль DTO-моделей токенов""" + from sqlmodel import SQLModel class Token(SQLModel): """Модель токена""" + access_token: str token_type: str = "bearer" refresh_token: str | None = None +class PartialToken(SQLModel): + """Частичный токен — для подтверждения 2FA""" + + partial_token: str + token_type: str = "partial" + requires_2fa: bool = True + + class TokenData(SQLModel): """Модель содержимого токена""" + username: str | None = None user_id: int | None = None + is_partial: bool = False diff --git a/library_service/models/dto/user.py b/library_service/models/dto/user.py index 8549100..2b95dcc 100644 --- a/library_service/models/dto/user.py +++ b/library_service/models/dto/user.py @@ -1,4 +1,5 @@ """Модуль DTO-моделей пользователей""" + import re from typing import List @@ -8,6 +9,7 @@ from sqlmodel import Field, SQLModel class UserBase(SQLModel): """Базовая модель пользователя""" + username: str = Field(min_length=3, max_length=50, index=True, unique=True) email: EmailStr = Field(index=True, unique=True) full_name: str | None = Field(default=None, max_length=100) @@ -25,6 +27,7 @@ class UserBase(SQLModel): class UserCreate(UserBase): """Модель пользователя для создания""" + password: str = Field(min_length=8, max_length=100) @field_validator("password") @@ -42,20 +45,24 @@ class UserCreate(UserBase): class UserLogin(SQLModel): """Модель аутентификации для пользователя""" + username: str password: str class UserRead(UserBase): """Модель пользователя для чтения""" + id: int is_active: bool is_verified: bool + is_2fa_enabled: bool roles: List[str] = [] class UserUpdate(SQLModel): """Модель пользователя для обновления""" + email: EmailStr | None = None full_name: str | None = None password: str | None = None @@ -63,5 +70,6 @@ class UserUpdate(SQLModel): class UserList(SQLModel): """Список пользователей""" + users: List[UserRead] total: int diff --git a/library_service/routers/auth.py b/library_service/routers/auth.py index 6cad767..7623d9c 100644 --- a/library_service/routers/auth.py +++ b/library_service/routers/auth.py @@ -2,9 +2,11 @@ from datetime import timedelta from typing import Annotated +from pathlib import Path from fastapi import APIRouter, Body, Depends, HTTPException, status, Request from fastapi.security import OAuth2PasswordRequestForm +from fastapi.templating import Jinja2Templates from sqlmodel import Session, select import pyotp @@ -17,7 +19,19 @@ from library_service.models.dto import ( UserList, RoleRead, RoleList, + Token, + PartialToken, + LoginResponse, + RecoveryCodeUse, + RegisterResponse, + RecoveryCodesStatus, + RecoveryCodesResponse, + PasswordResetResponse, + TOTPSetupResponse, + TOTPVerifyRequest, + TOTPDisableRequest, ) + from library_service.settings import get_session from library_service.auth import ( ACCESS_TOKEN_EXPIRE_MINUTES, @@ -29,10 +43,18 @@ from library_service.auth import ( decode_token, create_access_token, create_refresh_token, + generate_totp_setup, + generate_codes_for_user, + verify_and_use_code, + get_codes_status, + verify_totp_code, + verify_password, qr_to_bitmap_b64, + create_partial_token, + RequirePartialAuth, + verify_and_use_code, ) -from pathlib import Path -from fastapi.templating import Jinja2Templates + templates = Jinja2Templates(directory=Path(__file__).parent.parent / "templates") router = APIRouter(prefix="/auth", tags=["authentication"]) @@ -40,10 +62,10 @@ router = APIRouter(prefix="/auth", tags=["authentication"]) @router.post( "/register", - response_model=UserRead, + response_model=RegisterResponse, status_code=status.HTTP_201_CREATED, summary="Регистрация нового пользователя", - description="Создает нового пользователя в системе", + description="Создает нового пользователя и возвращает резервные коды", ) def register(user_data: UserCreate, session: Session = Depends(get_session)): """Регистрирует нового пользователя в системе""" @@ -61,7 +83,8 @@ def register(user_data: UserCreate, session: Session = Depends(get_session)): ).first() if existing_email: raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, detail="Email already registered" + status_code=status.HTTP_400_BAD_REQUEST, + detail="Email already registered", ) db_user = User( @@ -77,14 +100,25 @@ def register(user_data: UserCreate, session: Session = Depends(get_session)): session.commit() session.refresh(db_user) - return UserRead(**db_user.model_dump(), roles=[role.name for role in db_user.roles]) + recovery_codes = generate_codes_for_user(session, db_user) + + return RegisterResponse( + user=UserRead( + **db_user.model_dump(), + roles=[role.name for role in db_user.roles], + ), + recovery_codes=RecoveryCodesResponse( + codes=recovery_codes, + generated_at=db_user.recovery_codes_generated_at, + ), + ) @router.post( "/token", - response_model=Token, + response_model=LoginResponse, summary="Получение токена", - description="Аутентификация и получение JWT токена", + description="Аутентификация и получение токенов", ) def login( form_data: Annotated[OAuth2PasswordRequestForm, Depends()], @@ -99,17 +133,23 @@ def login( headers={"WWW-Authenticate": "Bearer"}, ) - access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) - access_token = create_access_token( - data={"sub": user.username, "user_id": user.id}, - expires_delta=access_token_expires, - ) - refresh_token = create_refresh_token( - data={"sub": user.username, "user_id": user.id} - ) + token_data = {"sub": user.username, "user_id": user.id} - return Token( - access_token=access_token, refresh_token=refresh_token, token_type="bearer" + if user.is_2fa_enabled: + return LoginResponse( + partial_token=create_partial_token(token_data), + token_type="partial", + requires_2fa=True, + ) + + access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + return LoginResponse( + access_token=create_access_token( + data=token_data, expires_delta=access_token_expires + ), + refresh_token=create_refresh_token(data=token_data), + token_type="bearer", + requires_2fa=False, ) @@ -330,18 +370,182 @@ def get_roles( @router.get( "/2fa", + response_model=TOTPSetupResponse, summary="Создание QR-кода TOTP 2FA", - description="Получить информацию о текущем авторизованном пользователе", + description="Генерирует секрет и QR-код для настройки TOTP", ) def get_totp_qr_bitmap(auth: RequireAuth): - """Возвращает qr-код bitmap""" - issuer = "issuer" - username = auth.username - secret = pyotp.random_base32() + """Возвращает данные для настройки TOTP""" + return TOTPSetupResponse(**generate_totp_setup(auth.username)) - totp = pyotp.TOTP(secret) - provisioning_uri = totp.provisioning_uri(name=username, issuer_name=issuer) - bitmap_data = qr_to_bitmap_b64(provisioning_uri) +@router.post( + "/2fa/enable", + summary="Включение TOTP 2FA", + description="Подтверждает настройку и включает 2FA", +) +def enable_2fa( + data: TOTPVerifyRequest, + current_user: RequireAuth, + secret: str = Body(..., embed=True), + session: Session = Depends(get_session), +): + """Включает 2FA после проверки кода""" + if current_user.is_2fa_enabled: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="2FA already enabled", + ) - return {"secret": secret, "username": username, "issuer": issuer, **bitmap_data} + if not verify_totp_code(secret, data.code): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid TOTP code", + ) + + current_user.totp_secret = secret + current_user.is_2fa_enabled = True + session.add(current_user) + session.commit() + + return {"success": True} + + +@router.post( + "/2fa/disable", + summary="Отключение TOTP 2FA", + description="Отключает 2FA после проверки пароля и кода", +) +def disable_2fa( + data: TOTPDisableRequest, + current_user: RequireAuth, + session: Session = Depends(get_session), +): + """Отключает 2FA""" + if not current_user.is_2fa_enabled or not current_user.totp_secret: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="2FA not enabled", + ) + + if not verify_password(data.password, current_user.hashed_password): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid password", + ) + + current_user.totp_secret = None + current_user.is_2fa_enabled = False + session.add(current_user) + session.commit() + + return {"success": True} + + +@router.post( + "/2fa/verify", + response_model=Token, + summary="Верификация 2FA", + description="Завершает аутентификацию с помощью TOTP кода или резервного кода", +) +def verify_2fa( + data: TOTPVerifyRequest, + user: RequirePartialAuth, + session: Session = Depends(get_session), +): + """Верифицирует 2FA и возвращает полный токен""" + if not data.code: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Provide TOTP code", + ) + + verified = False + + if data.code and user.totp_secret: + if verify_totp_code(user.totp_secret, data.code): + verified = True + + if not verified: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid 2FA code", + ) + + token_data = {"sub": user.username, "user_id": user.id} + access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + + return Token( + access_token=create_access_token( + data=token_data, expires_delta=access_token_expires + ), + refresh_token=create_refresh_token(data=token_data), + ) + + +@router.get( + "/recovery-codes/status", + response_model=RecoveryCodesStatus, + summary="Статус резервных кодов", + description="Показывает количество оставшихся кодов и какие использованы", +) +def get_recovery_codes_status(current_user: RequireAuth): + """Возвращает статус резервных кодов""" + return RecoveryCodesStatus(**get_codes_status(current_user)) + + +@router.post( + "/recovery-codes/regenerate", + response_model=RecoveryCodesResponse, + summary="Перегенерация резервных кодов", + description="Генерирует новые коды, старые аннулируются", +) +def regenerate_recovery_codes( + current_user: RequireAuth, + session: Session = Depends(get_session), +): + """Генерирует новые резервные коды""" + codes = generate_codes_for_user(session, current_user) + + return RecoveryCodesResponse( + codes=codes, + generated_at=current_user.recovery_codes_generated_at, + ) + + +@router.post( + "/password/reset", + response_model=PasswordResetResponse, + summary="Сброс пароля через резервный код", + description="Устанавливает новый пароль используя резервный код", +) +def reset_password( + data: RecoveryCodeUse, + session: Session = Depends(get_session), +): + """Сброс пароля с использованием резервного кода""" + user = session.exec(select(User).where(User.username == data.username)).first() + + if not user: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid username or recovery code", + ) + + if not user.is_active: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Account is deactivated", + ) + + if not verify_and_use_code(session, user, data.recovery_code): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid username or recovery code", + ) + + user.hashed_password = get_password_hash(data.new_password) + session.add(user) + session.commit() + + return PasswordResetResponse(**get_codes_status(user)) diff --git a/library_service/routers/books.py b/library_service/routers/books.py index 8839b09..b9db8ef 100644 --- a/library_service/routers/books.py +++ b/library_service/routers/books.py @@ -1,5 +1,6 @@ """Модуль работы с книгами""" -from datetime import datetime + +from datetime import datetime, timezone from typing import List from fastapi import APIRouter, Depends, HTTPException, Path, Query @@ -8,11 +9,25 @@ from sqlmodel import Session, select, col, func from library_service.auth import RequireStaff from library_service.settings import get_session from library_service.models.enums import BookStatus -from library_service.models.db import Author, AuthorBookLink, Book, GenreBookLink, Genre, BookUserLink -from library_service.models.dto import AuthorRead, BookCreate, BookList, BookRead, BookUpdate, GenreRead +from library_service.models.db import ( + Author, + AuthorBookLink, + Book, + GenreBookLink, + Genre, + BookUserLink, +) +from library_service.models.dto import ( + AuthorRead, + BookCreate, + BookList, + BookRead, + BookUpdate, + GenreRead, +) from library_service.models.dto.combined import ( BookWithAuthorsAndGenres, - BookFilteredList + BookFilteredList, ) @@ -28,7 +43,7 @@ def close_active_loan(session: Session, book_id: int) -> None: ).first() if active_loan: - active_loan.returned_at = datetime.utcnow() + active_loan.returned_at = datetime.now(timezone.utc) session.add(active_loan) @@ -36,7 +51,7 @@ def close_active_loan(session: Session, book_id: int) -> None: "/filter", response_model=BookFilteredList, summary="Фильтрация книг", - description="Фильтрация списка книг по названию, авторам и жанрам с пагинацией" + description="Фильтрация списка книг по названию, авторам и жанрам с пагинацией", ) def filter_books( session: Session = Depends(get_session), @@ -55,10 +70,14 @@ def filter_books( ) if author_ids: - statement = statement.join(AuthorBookLink).where(AuthorBookLink.author_id.in_(author_ids)) + statement = statement.join(AuthorBookLink).where( + AuthorBookLink.author_id.in_(author_ids) + ) # ty: ignore[unresolved-attribute, unresolved-reference] if genre_ids: - statement = statement.join(GenreBookLink).where(GenreBookLink.genre_id.in_(genre_ids)) + statement = statement.join(GenreBookLink).where( + GenreBookLink.genre_id.in_(genre_ids) + ) # ty: ignore[unresolved-attribute, unresolved-reference] total_statement = select(func.count()).select_from(statement.subquery()) total = session.exec(total_statement).one() @@ -73,7 +92,7 @@ def filter_books( BookWithAuthorsAndGenres( **db_book.model_dump(), authors=[AuthorRead(**a.model_dump()) for a in db_book.authors], - genres=[GenreRead(**g.model_dump()) for g in db_book.genres] + genres=[GenreRead(**g.model_dump()) for g in db_book.genres], ) ) @@ -89,7 +108,7 @@ def filter_books( def create_book( book: BookCreate, current_user: RequireStaff, - session: Session = Depends(get_session) + session: Session = Depends(get_session), ): """Создает новую книгу в системе""" db_book = Book(**book.model_dump()) @@ -168,7 +187,7 @@ def update_book( if book_update.status == BookStatus.BORROWED: raise HTTPException( status_code=400, - detail="Статус 'borrowed' устанавливается только через выдачу книги" + detail="Статус 'borrowed' устанавливается только через выдачу книги", ) if db_book.status == BookStatus.BORROWED: @@ -205,7 +224,10 @@ def delete_book( if not book: raise HTTPException(status_code=404, detail="Book not found") book_read = BookRead( - id=(book.id or 0), title=book.title, description=book.description, status=book.status + id=(book.id or 0), + title=book.title, + description=book.description, + status=book.status, ) session.delete(book) session.commit() diff --git a/library_service/routers/loans.py b/library_service/routers/loans.py index d271e59..9abd970 100644 --- a/library_service/routers/loans.py +++ b/library_service/routers/loans.py @@ -1,5 +1,6 @@ """Модуль работы с выдачей и бронированием книг""" -from datetime import datetime, timedelta + +from datetime import datetime, timedelta, timezone from typing import Dict, List from fastapi import APIRouter, Depends, HTTPException, Path, Query, status @@ -34,7 +35,7 @@ def create_loan( if not is_staff and loan.user_id != current_user.id: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="You can only create loans for yourself" + detail="You can only create loans for yourself", ) book = session.get(Book, loan.book_id) @@ -44,7 +45,7 @@ def create_loan( if book.status != BookStatus.ACTIVE: raise HTTPException( status_code=400, - detail=f"Book is not available for loan (status: {book.status})" + detail=f"Book is not available for loan (status: {book.status})", ) target_user = session.get(User, loan.user_id) @@ -55,7 +56,7 @@ def create_loan( book_id=loan.book_id, user_id=loan.user_id, due_date=loan.due_date, - borrowed_at=datetime.utcnow() + borrowed_at=datetime.now(timezone.utc), ) book.status = BookStatus.RESERVED @@ -109,8 +110,7 @@ def read_loans( loans = session.exec(statement).all() return LoanList( - loans=[LoanRead(**loan.model_dump()) for loan in loans], - total=total + loans=[LoanRead(**loan.model_dump()) for loan in loans], total=total ) @@ -125,11 +125,12 @@ def get_loans_analytics( session: Session = Depends(get_session), ): """Возвращает аналитику по выдачам и возвратам книг""" - end_date = datetime.utcnow() + end_date = datetime.now(timezone.utc) start_date = end_date - timedelta(days=days) total_loans = session.exec( - select(func.count(BookUserLink.id)) - .where(BookUserLink.borrowed_at >= start_date) + select(func.count(BookUserLink.id)).where( + BookUserLink.borrowed_at >= start_date + ) ).one() active_loans = session.exec( @@ -156,7 +157,7 @@ def get_loans_analytics( loans_by_date = session.exec( select( cast(BookUserLink.borrowed_at, Date).label("date"), - func.count(BookUserLink.id).label("count") + func.count(BookUserLink.id).label("count"), ) .where(BookUserLink.borrowed_at >= start_date) .group_by(cast(BookUserLink.borrowed_at, Date)) @@ -166,9 +167,11 @@ def get_loans_analytics( returns_by_date = session.exec( select( cast(BookUserLink.returned_at, Date).label("date"), - func.count(BookUserLink.id).label("count") + func.count(BookUserLink.id).label("count"), + ) + .where( + BookUserLink.returned_at >= start_date # ty: ignore[unsupported-operator] ) - .where(BookUserLink.returned_at >= start_date) .where(BookUserLink.returned_at != None) # noqa: E711 .group_by(cast(BookUserLink.returned_at, Date)) .order_by(cast(BookUserLink.returned_at, Date)) @@ -185,10 +188,7 @@ def get_loans_analytics( daily_returns[date_str] = count top_books = session.exec( - select( - BookUserLink.book_id, - func.count(BookUserLink.id).label("loan_count") - ) + select(BookUserLink.book_id, func.count(BookUserLink.id).label("loan_count")) .where(BookUserLink.borrowed_at >= start_date) .group_by(BookUserLink.book_id) .order_by(func.count(BookUserLink.id).desc()) @@ -201,38 +201,36 @@ def get_loans_analytics( loan_count = row[1] if isinstance(row, tuple) else row.loan_count book = session.get(Book, book_id) if book: - top_books_data.append({ - "book_id": book_id, - "title": book.title, - "loan_count": loan_count - }) + top_books_data.append( + {"book_id": book_id, "title": book.title, "loan_count": loan_count} + ) reserved_count = session.exec( - select(func.count(Book.id)) - .where(Book.status == BookStatus.RESERVED) + select(func.count(Book.id)).where(Book.status == BookStatus.RESERVED) ).one() borrowed_count = session.exec( - select(func.count(Book.id)) - .where(Book.status == BookStatus.BORROWED) + select(func.count(Book.id)).where(Book.status == BookStatus.BORROWED) ).one() - return JSONResponse(content={ - "summary": { - "total_loans": total_loans, - "active_loans": active_loans, - "returned_loans": returned_loans, - "overdue_loans": overdue_loans, - "reserved_books": reserved_count, - "borrowed_books": borrowed_count, - }, - "daily_loans": daily_loans, - "daily_returns": daily_returns, - "top_books": top_books_data, - "period_days": days, - "start_date": start_date.isoformat(), - "end_date": end_date.isoformat(), - }) + return JSONResponse( + content={ + "summary": { + "total_loans": total_loans, + "active_loans": active_loans, + "returned_loans": returned_loans, + "overdue_loans": overdue_loans, + "reserved_books": reserved_count, + "borrowed_books": borrowed_count, + }, + "daily_loans": daily_loans, + "daily_returns": daily_returns, + "top_books": top_books_data, + "period_days": days, + "start_date": start_date.isoformat(), + "end_date": end_date.isoformat(), + } + ) @router.get( @@ -256,8 +254,7 @@ def get_loan( if not is_staff and loan.user_id != current_user.id: raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Access denied to this loan" + status_code=status.HTTP_403_FORBIDDEN, detail="Access denied to this loan" ) return LoanRead(**loan.model_dump()) @@ -285,7 +282,7 @@ def update_loan( if not is_staff and db_loan.user_id != current_user.id: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="You can only update your own loans" + detail="You can only update your own loans", ) book = session.get(Book, db_loan.book_id) @@ -296,7 +293,7 @@ def update_loan( if not is_staff: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Only staff can change loan user" + detail="Only staff can change loan user", ) new_user = session.get(User, loan_update.user_id) if not new_user: @@ -308,10 +305,7 @@ def update_loan( if loan_update.returned_at is not None: if db_loan.returned_at is not None: - raise HTTPException( - status_code=400, - detail="Loan is already returned" - ) + raise HTTPException(status_code=400, detail="Loan is already returned") db_loan.returned_at = loan_update.returned_at book.status = BookStatus.ACTIVE @@ -349,7 +343,7 @@ def confirm_loan( if book.status not in [BookStatus.RESERVED, BookStatus.ACTIVE]: raise HTTPException( status_code=400, - detail=f"Cannot confirm loan for book with status: {book.status}" + detail=f"Cannot confirm loan for book with status: {book.status}", ) book.status = BookStatus.BORROWED @@ -381,7 +375,7 @@ def return_loan( if loan.returned_at: raise HTTPException(status_code=400, detail="Loan is already returned") - loan.returned_at = datetime.utcnow() + loan.returned_at = datetime.now(timezone.utc) book = session.get(Book, loan.book_id) if book: @@ -416,7 +410,7 @@ def delete_loan( if not is_staff and loan.user_id != current_user.id: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="You can only delete your own loans" + detail="You can only delete your own loans", ) book = session.get(Book, loan.book_id) @@ -424,7 +418,7 @@ def delete_loan( if book and book.status != BookStatus.RESERVED: raise HTTPException( status_code=400, - detail="Can only delete reservations. Use update endpoint to return borrowed books" + detail="Can only delete reservations. Use update endpoint to return borrowed books", ) loan_read = LoanRead(**loan.model_dump()) @@ -481,8 +475,7 @@ def issue_book_directly( if book.status != BookStatus.ACTIVE: raise HTTPException( - status_code=400, - detail=f"Book is not available (status: {book.status})" + status_code=400, detail=f"Book is not available (status: {book.status})" ) target_user = session.get(User, loan.user_id) @@ -493,7 +486,7 @@ def issue_book_directly( book_id=loan.book_id, user_id=loan.user_id, due_date=loan.due_date, - borrowed_at=datetime.utcnow() + borrowed_at=datetime.now(timezone.utc), ) book.status = BookStatus.BORROWED diff --git a/library_service/routers/misc.py b/library_service/routers/misc.py index a13e7ee..f096d6f 100644 --- a/library_service/routers/misc.py +++ b/library_service/routers/misc.py @@ -103,7 +103,7 @@ async def auth(request: Request): return templates.TemplateResponse(request, "auth.html") -@router.get("/set-2fa", include_in_schema=False) +@router.get("/2fa", include_in_schema=False) async def set2fa(request: Request): """Рендерит страницу установки двухфакторной аутентификации""" return templates.TemplateResponse(request, "2fa.html") diff --git a/library_service/static/auth.js b/library_service/static/auth.js deleted file mode 100644 index 9bb33d0..0000000 --- a/library_service/static/auth.js +++ /dev/null @@ -1,152 +0,0 @@ -$(() => { - $("#login-tab").on("click", function () { - $(this) - .removeClass("text-gray-400 hover:text-gray-600") - .addClass("text-gray-700 bg-gray-50 border-b-2 border-gray-500"); - $("#register-tab") - .removeClass("text-gray-700 bg-gray-50 border-b-2 border-gray-500") - .addClass("text-gray-400 hover:text-gray-600"); - - $("#login-form").removeClass("hidden"); - $("#register-form").addClass("hidden"); - }); - - $("#register-tab").on("click", function () { - $(this) - .removeClass("text-gray-400 hover:text-gray-600") - .addClass("text-gray-700 bg-gray-50 border-b-2 border-gray-500"); - $("#login-tab") - .removeClass("text-gray-700 bg-gray-50 border-b-2 border-gray-500") - .addClass("text-gray-400 hover:text-gray-600"); - - $("#register-form").removeClass("hidden"); - $("#login-form").addClass("hidden"); - }); - - $("body").on("click", ".toggle-password", function () { - const $btn = $(this); - const $input = $btn.siblings("input"); - - const isPassword = $input.attr("type") === "password"; - $input.attr("type", isPassword ? "text" : "password"); - $btn.find("svg").toggleClass("hidden"); - }); - - $("#register-password").on("input", function () { - const password = $(this).val(); - let strength = 0; - - if (password.length >= 8) strength++; - if (password.length >= 12) strength++; - if (/[a-z]/.test(password) && /[A-Z]/.test(password)) strength++; - if (/\d/.test(password)) strength++; - if (/[^a-zA-Z0-9]/.test(password)) strength++; - - const levels = [ - { width: "0%", color: "", text: "" }, - { width: "20%", color: "bg-red-500", text: "Очень слабый" }, - { width: "40%", color: "bg-orange-500", text: "Слабый" }, - { width: "60%", color: "bg-yellow-500", text: "Средний" }, - { width: "80%", color: "bg-lime-500", text: "Хороший" }, - { width: "100%", color: "bg-green-500", text: "Отличный" }, - ]; - - const level = levels[strength]; - const $bar = $("#password-strength-bar"); - - $bar.css("width", level.width); - $bar.attr("class", "h-full transition-all duration-300 " + level.color); - $("#password-strength-text").text(level.text); - - checkPasswordMatch(); - }); - - function checkPasswordMatch() { - const password = $("#register-password").val(); - const confirm = $("#register-password-confirm").val(); - const $error = $("#password-match-error"); - - if (confirm && password !== confirm) { - $error.removeClass("hidden"); - return false; - } else { - $error.addClass("hidden"); - return true; - } - } - - $("#register-password-confirm").on("input", checkPasswordMatch); - - $("#login-form").on("submit", async function (event) { - event.preventDefault(); - const $submitBtn = $("#login-submit"); - const username = $("#login-username").val(); - const password = $("#login-password").val(); - - const rememberMe = $("#remember-me").prop("checked"); - $submitBtn.prop("disabled", true).text("Вход..."); - - try { - const formData = new URLSearchParams(); - formData.append("username", username); - formData.append("password", password); - - const data = await Api.postForm("/api/auth/token", formData); - const storage = rememberMe ? localStorage : sessionStorage; - - storage.setItem("access_token", data.access_token); - if (rememberMe && data.refresh_token) { - storage.setItem("refresh_token", data.refresh_token); - } - - const otherStorage = rememberMe ? sessionStorage : localStorage; - otherStorage.removeItem("access_token"); - otherStorage.removeItem("refresh_token"); - - window.location.href = "/"; - } catch (error) { - Utils.showToast(error.message || "Ошибка входа", "error"); - } finally { - $submitBtn.prop("disabled", false).text("Войти"); - } - }); - - $("#register-form").on("submit", async function (event) { - event.preventDefault(); - const $submitBtn = $("#register-submit"); - const pass = $("#register-password").val(); - const confirm = $("#register-password-confirm").val(); - - if (pass !== confirm) { - Utils.showToast("Пароли не совпадают", "error"); - return; - } - - const userData = { - username: $("#register-username").val(), - email: $("#register-email").val(), - full_name: $("#register-fullname").val() || null, - password: pass, - }; - - $submitBtn.prop("disabled", true).text("Регистрация..."); - - try { - await Api.post("/api/auth/register", userData); - Utils.showToast("Регистрация успешна! Войдите в систему.", "success"); - - setTimeout(() => { - $("#login-tab").trigger("click"); - $("#login-username").val(userData.username); - }, 1500); - } catch (error) { - let msg = error.message; - if (Array.isArray(error.detail)) { - msg = error.detail.map((e) => e.msg).join(". "); - } - Utils.showToast(msg || "Ошибка регистрации", "error"); - } finally { - $submitBtn.prop("disabled", false).text("Зарегистрироваться"); - } - }); -}); diff --git a/library_service/static/2fa.js b/library_service/static/page/2fa.js similarity index 99% rename from library_service/static/2fa.js rename to library_service/static/page/2fa.js index bc26386..9541039 100644 --- a/library_service/static/2fa.js +++ b/library_service/static/page/2fa.js @@ -177,8 +177,10 @@ $(async () => { $msg.text("").attr("class", "mb-4 text-center text-sm min-h-[20px]"); try { - await Api.post("/api/auth/2fa/verify", { - code: code, + await Api.post("/api/auth/2fa/enable", { + data: { + code: code, + }, secret: secretKey, }); diff --git a/library_service/static/analytics.js b/library_service/static/page/analytics.js similarity index 95% rename from library_service/static/analytics.js rename to library_service/static/page/analytics.js index 0f2da6d..4d36066 100644 --- a/library_service/static/analytics.js +++ b/library_service/static/page/analytics.js @@ -1,7 +1,7 @@ $(document).ready(() => { if (!window.isAdmin()) { $(".container").html( - '

Доступ запрещён

Только администраторы могут просматривать аналитику

На главную
' + '

Доступ запрещён

Только администраторы могут просматривать аналитику

На главную
', ); return; } @@ -45,21 +45,28 @@ $(document).ready(() => { } function renderCharts(data) { - // Подготовка данных для графиков const startDate = new Date(data.start_date); const endDate = new Date(data.end_date); const dates = []; const loansData = []; const returnsData = []; - for (let d = new Date(startDate); d <= endDate; d.setDate(d.getDate() + 1)) { + for ( + let d = new Date(startDate); + d <= endDate; + d.setDate(d.getDate() + 1) + ) { const dateStr = d.toISOString().split("T")[0]; - dates.push(new Date(d).toLocaleDateString("ru-RU", { day: "2-digit", month: "2-digit" })); + dates.push( + new Date(d).toLocaleDateString("ru-RU", { + day: "2-digit", + month: "2-digit", + }), + ); loansData.push(data.daily_loans[dateStr] || 0); returnsData.push(data.daily_returns[dateStr] || 0); } - // График выдач const loansCtx = document.getElementById("loans-chart"); if (loansChart) { loansChart.destroy(); @@ -141,7 +148,6 @@ $(document).ready(() => { }, }); - // График возвратов const returnsCtx = document.getElementById("returns-chart"); if (returnsChart) { returnsChart.destroy(); @@ -230,7 +236,7 @@ $(document).ready(() => { if (!topBooks || topBooks.length === 0) { $container.html( - '
Нет данных
' + '
Нет данных
', ); return; } @@ -259,4 +265,3 @@ $(document).ready(() => { }); } }); - diff --git a/library_service/static/page/auth.js b/library_service/static/page/auth.js new file mode 100644 index 0000000..d815b36 --- /dev/null +++ b/library_service/static/page/auth.js @@ -0,0 +1,553 @@ +$(() => { + const PARTIAL_TOKEN_KEY = "partial_token"; + const PARTIAL_USERNAME_KEY = "partial_username"; + const TOTP_PERIOD = 30; + const CIRCLE_CIRCUMFERENCE = 2 * Math.PI * 38; + + let loginState = { + step: "credentials", + partialToken: null, + username: "", + rememberMe: false, + }; + + let registeredRecoveryCodes = []; + let totpAnimationFrame = null; + + function getTotpProgress() { + const now = Date.now() / 1000; + const elapsed = now % TOTP_PERIOD; + return elapsed / TOTP_PERIOD; + } + + function updateTotpTimer() { + const circle = document.getElementById("lock-progress-circle"); + if (!circle) return; + + const progress = getTotpProgress(); + const offset = CIRCLE_CIRCUMFERENCE * (1 - progress); + circle.style.strokeDashoffset = offset; + + totpAnimationFrame = requestAnimationFrame(updateTotpTimer); + } + + function startTotpTimer() { + stopTotpTimer(); + updateTotpTimer(); + } + + function stopTotpTimer() { + if (totpAnimationFrame) { + cancelAnimationFrame(totpAnimationFrame); + totpAnimationFrame = null; + } + } + + function resetCircle() { + const circle = document.getElementById("lock-progress-circle"); + if (circle) { + circle.style.strokeDashoffset = CIRCLE_CIRCUMFERENCE; + } + } + + function initLoginState() { + const savedToken = sessionStorage.getItem(PARTIAL_TOKEN_KEY); + const savedUsername = sessionStorage.getItem(PARTIAL_USERNAME_KEY); + + if (savedToken && savedUsername) { + loginState.partialToken = savedToken; + loginState.username = savedUsername; + loginState.step = "2fa"; + + $("#login-username").val(savedUsername); + $("#credentials-section").addClass("hidden"); + $("#totp-section").removeClass("hidden"); + $("#login-submit").text("Подтвердить"); + + startTotpTimer(); + + setTimeout(() => { + const totpInput = document.getElementById("login-totp"); + if (totpInput) totpInput.focus(); + }, 100); + } + } + + function savePartialToken(token, username) { + sessionStorage.setItem(PARTIAL_TOKEN_KEY, token); + sessionStorage.setItem(PARTIAL_USERNAME_KEY, username); + } + + function clearPartialToken() { + sessionStorage.removeItem(PARTIAL_TOKEN_KEY); + sessionStorage.removeItem(PARTIAL_USERNAME_KEY); + } + + function showForm(formId) { + $("#login-form, #register-form, #reset-password-form").addClass("hidden"); + $(formId).removeClass("hidden"); + + $("#login-tab, #register-tab") + .removeClass("text-gray-700 bg-gray-50 border-b-2 border-gray-500") + .addClass("text-gray-400 hover:text-gray-600"); + + if (formId === "#login-form") { + $("#login-tab") + .removeClass("text-gray-400 hover:text-gray-600") + .addClass("text-gray-700 bg-gray-50 border-b-2 border-gray-500"); + resetLoginState(); + } else if (formId === "#register-form") { + $("#register-tab") + .removeClass("text-gray-400 hover:text-gray-600") + .addClass("text-gray-700 bg-gray-50 border-b-2 border-gray-500"); + } + } + + function resetLoginState() { + clearPartialToken(); + stopTotpTimer(); + loginState = { + step: "credentials", + partialToken: null, + username: "", + rememberMe: false, + }; + $("#totp-section").addClass("hidden"); + $("#login-totp").val(""); + $("#credentials-section").removeClass("hidden"); + $("#login-submit").text("Войти"); + resetCircle(); + } + + $("#login-tab").on("click", () => showForm("#login-form")); + $("#register-tab").on("click", () => showForm("#register-form")); + $("#forgot-password-btn").on("click", () => showForm("#reset-password-form")); + $("#back-to-login-btn").on("click", () => showForm("#login-form")); + + $("body").on("click", ".toggle-password", function () { + const $btn = $(this); + const $input = $btn.siblings("input"); + const isPassword = $input.attr("type") === "password"; + $input.attr("type", isPassword ? "text" : "password"); + $btn.find("svg").toggleClass("hidden"); + }); + + $("#register-password").on("input", function () { + const password = $(this).val(); + let strength = 0; + + if (password.length >= 8) strength++; + if (password.length >= 12) strength++; + if (/[a-z]/.test(password) && /[A-Z]/.test(password)) strength++; + if (/\d/.test(password)) strength++; + if (/[^a-zA-Z0-9]/.test(password)) strength++; + + const levels = [ + { width: "0%", color: "", text: "" }, + { width: "20%", color: "bg-red-500", text: "Очень слабый" }, + { width: "40%", color: "bg-orange-500", text: "Слабый" }, + { width: "60%", color: "bg-yellow-500", text: "Средний" }, + { width: "80%", color: "bg-lime-500", text: "Хороший" }, + { width: "100%", color: "bg-green-500", text: "Отличный" }, + ]; + + const level = levels[strength]; + $("#password-strength-bar") + .css("width", level.width) + .attr("class", "h-full transition-all duration-300 " + level.color); + $("#password-strength-text").text(level.text); + + checkPasswordMatch(); + }); + + function checkPasswordMatch() { + const password = $("#register-password").val(); + const confirm = $("#register-password-confirm").val(); + if (confirm && password !== confirm) { + $("#password-match-error").removeClass("hidden"); + return false; + } + $("#password-match-error").addClass("hidden"); + return true; + } + + $("#register-password-confirm").on("input", checkPasswordMatch); + + function formatRecoveryCode(input) { + let value = input.value.toUpperCase().replace(/[^0-9A-F]/g, ""); + let formatted = ""; + for (let i = 0; i < value.length && i < 16; i++) { + if (i > 0 && i % 4 === 0) formatted += "-"; + formatted += value[i]; + } + input.value = formatted; + } + + $("#reset-recovery-code").on("input", function () { + formatRecoveryCode(this); + }); + + $("#login-totp").on("input", function () { + this.value = this.value.replace(/\D/g, "").slice(0, 6); + if (this.value.length === 6) { + $("#login-form").trigger("submit"); + } + }); + + $("#back-to-credentials-btn").on("click", function () { + resetLoginState(); + }); + + $("#login-form").on("submit", async function (event) { + event.preventDefault(); + const $submitBtn = $("#login-submit"); + + if (loginState.step === "credentials") { + const username = $("#login-username").val(); + const password = $("#login-password").val(); + const rememberMe = $("#remember-me").prop("checked"); + + loginState.username = username; + loginState.rememberMe = rememberMe; + + $submitBtn.prop("disabled", true).text("Вход..."); + + try { + const formData = new URLSearchParams(); + formData.append("username", username); + formData.append("password", password); + + const data = await Api.postForm("/api/auth/token", formData); + + if (data.requires_2fa && data.partial_token) { + loginState.partialToken = data.partial_token; + loginState.step = "2fa"; + + savePartialToken(data.partial_token, username); + + $("#credentials-section").addClass("hidden"); + $("#totp-section").removeClass("hidden"); + + startTotpTimer(); + + const totpInput = document.getElementById("login-totp"); + if (totpInput) totpInput.focus(); + + $submitBtn.text("Подтвердить"); + Utils.showToast("Введите код из приложения аутентификатора", "info"); + } else if (data.access_token) { + clearPartialToken(); + saveTokensAndRedirect(data, rememberMe); + } + } catch (error) { + Utils.showToast(error.message || "Ошибка входа", "error"); + } finally { + $submitBtn.prop("disabled", false); + if (loginState.step === "credentials") { + $submitBtn.text("Войти"); + } + } + } else if (loginState.step === "2fa") { + const totpCode = $("#login-totp").val(); + + if (!totpCode || totpCode.length !== 6) { + Utils.showToast("Введите 6-значный код", "error"); + return; + } + + $submitBtn.prop("disabled", true).text("Проверка..."); + + try { + const response = await fetch("/api/auth/2fa/verify", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${loginState.partialToken}`, + }, + body: JSON.stringify({ code: totpCode }), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + + if (response.status === 401) { + resetLoginState(); + throw new Error( + "Время сессии истекло. Пожалуйста, войдите заново.", + ); + } + + throw new Error(errorData.detail || "Неверный код"); + } + + const data = await response.json(); + clearPartialToken(); + stopTotpTimer(); + saveTokensAndRedirect(data, loginState.rememberMe); + } catch (error) { + Utils.showToast(error.message || "Неверный код", "error"); + $("#login-totp").val(""); + const totpInput = document.getElementById("login-totp"); + if (totpInput) totpInput.focus(); + } finally { + $submitBtn.prop("disabled", false).text("Подтвердить"); + } + } + }); + + function saveTokensAndRedirect(data, rememberMe) { + const storage = rememberMe ? localStorage : sessionStorage; + const otherStorage = rememberMe ? sessionStorage : localStorage; + + storage.setItem("access_token", data.access_token); + if (data.refresh_token) { + storage.setItem("refresh_token", data.refresh_token); + } + + otherStorage.removeItem("access_token"); + otherStorage.removeItem("refresh_token"); + + window.location.href = "/"; + } + + $("#register-form").on("submit", async function (event) { + event.preventDefault(); + const $submitBtn = $("#register-submit"); + const pass = $("#register-password").val(); + const confirm = $("#register-password-confirm").val(); + + if (pass !== confirm) { + Utils.showToast("Пароли не совпадают", "error"); + return; + } + + const userData = { + username: $("#register-username").val(), + email: $("#register-email").val(), + full_name: $("#register-fullname").val() || null, + password: pass, + }; + + $submitBtn.prop("disabled", true).text("Регистрация..."); + + try { + const response = await Api.post("/api/auth/register", userData); + + if (response.recovery_codes && response.recovery_codes.codes) { + registeredRecoveryCodes = response.recovery_codes.codes; + showRecoveryCodesModal(registeredRecoveryCodes, userData.username); + } else { + Utils.showToast("Регистрация успешна! Войдите в систему.", "success"); + setTimeout(() => { + showForm("#login-form"); + $("#login-username").val(userData.username); + }, 1500); + } + } catch (error) { + let msg = error.message; + if (error.detail && Array.isArray(error.detail)) { + msg = error.detail.map((e) => e.msg).join(". "); + } + Utils.showToast(msg || "Ошибка регистрации", "error"); + } finally { + $submitBtn.prop("disabled", false).text("Зарегистрироваться"); + } + }); + + function showRecoveryCodesModal(codes, username) { + const $list = $("#recovery-codes-list"); + $list.empty(); + + codes.forEach((code, index) => { + $list.append(` +
+ ${index + 1}. ${Utils.escapeHtml(code)} +
+ `); + }); + + $("#codes-saved-checkbox").prop("checked", false); + $("#close-recovery-modal-btn").prop("disabled", true); + $("#recovery-codes-modal").data("username", username); + $("#recovery-codes-modal").removeClass("hidden"); + } + + function renderRecoveryCodesStatus(usedCodes) { + return usedCodes + .map((used, index) => { + const codeDisplay = "████-████-████-████"; + const statusClass = used + ? "text-gray-300 line-through" + : "text-green-600"; + const statusIcon = used ? "✗" : "✓"; + return ` +
+ ${index + 1}. ${codeDisplay} + ${statusIcon} +
+ `; + }) + .join(""); + } + + $("#codes-saved-checkbox").on("change", function () { + $("#close-recovery-modal-btn").prop("disabled", !this.checked); + }); + + $("#copy-codes-btn").on("click", function () { + const codesText = registeredRecoveryCodes.join("\n"); + navigator.clipboard.writeText(codesText).then(() => { + Utils.showToast("Коды скопированы в буфер обмена", "success"); + }); + }); + + $("#download-codes-btn").on("click", function () { + const username = $("#recovery-codes-modal").data("username") || "user"; + const codesText = `Резервные коды для аккаунта: ${username}\nДата: ${new Date().toLocaleString()}\n\n${registeredRecoveryCodes.map((c, i) => `${i + 1}. ${c}`).join("\n")}\n\nХраните эти коды в надёжном месте!`; + + const blob = new Blob([codesText], { type: "text/plain;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `recovery-codes-${username}.txt`; + a.click(); + URL.revokeObjectURL(url); + + Utils.showToast("Файл с кодами скачан", "success"); + }); + + $("#close-recovery-modal-btn").on("click", function () { + const username = $("#recovery-codes-modal").data("username"); + $("#recovery-codes-modal").addClass("hidden"); + + Utils.showToast("Регистрация успешна! Войдите в систему.", "success"); + showForm("#login-form"); + $("#login-username").val(username); + }); + + function checkResetPasswordMatch() { + const password = $("#reset-new-password").val(); + const confirm = $("#reset-confirm-password").val(); + if (confirm && password !== confirm) { + $("#reset-password-match-error").removeClass("hidden"); + return false; + } + $("#reset-password-match-error").addClass("hidden"); + return true; + } + + $("#reset-confirm-password").on("input", checkResetPasswordMatch); + + $("#reset-password-form").on("submit", async function (event) { + event.preventDefault(); + const $submitBtn = $("#reset-submit"); + + const newPassword = $("#reset-new-password").val(); + const confirmPassword = $("#reset-confirm-password").val(); + + if (newPassword !== confirmPassword) { + Utils.showToast("Пароли не совпадают", "error"); + return; + } + + if (newPassword.length < 8) { + Utils.showToast("Пароль должен содержать минимум 8 символов", "error"); + return; + } + + const data = { + username: $("#reset-username").val(), + recovery_code: $("#reset-recovery-code").val().toUpperCase(), + new_password: newPassword, + }; + + if ( + !/^[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}$/.test( + data.recovery_code, + ) + ) { + Utils.showToast("Неверный формат резервного кода", "error"); + return; + } + + $submitBtn.prop("disabled", true).text("Сброс..."); + + try { + const response = await Api.post("/api/auth/password/reset", data); + + showPasswordResetResult(response, data.username); + } catch (error) { + Utils.showToast(error.message || "Ошибка сброса пароля", "error"); + $submitBtn.prop("disabled", false).text("Сбросить пароль"); + } + }); + + function showPasswordResetResult(response, username) { + const $form = $("#reset-password-form"); + + $form.html(` +
+
+ + + +
+

Пароль успешно изменён!

+
+ +
+

+ Осталось резервных кодов: ${response.remaining} из ${response.total} +

+ + ${ + response.should_regenerate + ? ` +
+

+ + + + Рекомендуем сгенерировать новые коды в профиле +

+
+ ` + : "" + } + +
+

Статус резервных кодов:

+ ${renderRecoveryCodesStatus(response.used_codes)} +
+ + ${ + response.generated_at + ? ` +

+ Сгенерированы: ${new Date(response.generated_at).toLocaleString()} +

+ ` + : "" + } +
+ + + `); + + $form.off("submit"); + + $("#goto-login-after-reset").on("click", function () { + location.reload(); + setTimeout(() => { + showForm("#login-form"); + $("#login-username").val(username); + }, 100); + }); + } + + initLoginState(); +}); diff --git a/library_service/static/author.js b/library_service/static/page/author.js similarity index 100% rename from library_service/static/author.js rename to library_service/static/page/author.js diff --git a/library_service/static/authors.js b/library_service/static/page/authors.js similarity index 100% rename from library_service/static/authors.js rename to library_service/static/page/authors.js diff --git a/library_service/static/book.js b/library_service/static/page/book.js similarity index 98% rename from library_service/static/book.js rename to library_service/static/page/book.js index acf5819..899b1a3 100644 --- a/library_service/static/book.js +++ b/library_service/static/page/book.js @@ -103,7 +103,7 @@ $(document).ready(() => { try { const data = await Api.get( - `/api/loans/?book_id=${bookId}&active_only=true&page=1&size=10` + `/api/loans/?book_id=${bookId}&active_only=true&page=1&size=10`, ); activeLoan = data.loans.length > 0 ? data.loans[0] : null; renderLoans(data.loans); @@ -128,7 +128,7 @@ $(document).ready(() => { loans.forEach((loan) => { const borrowedDate = new Date(loan.borrowed_at).toLocaleDateString( - "ru-RU" + "ru-RU", ); const dueDate = new Date(loan.due_date).toLocaleDateString("ru-RU"); const isOverdue = @@ -531,11 +531,9 @@ $(document).ready(() => { due_date: new Date(dueDate).toISOString(), }; - // Используем прямой эндпоинт выдачи для администраторов if (window.isAdmin()) { await Api.post("/api/loans/issue", payload); } else { - // Для библиотекарей создаем бронь, которую потом нужно подтвердить await Api.post("/api/loans/", payload); } diff --git a/library_service/static/books.js b/library_service/static/page/books.js similarity index 100% rename from library_service/static/books.js rename to library_service/static/page/books.js diff --git a/library_service/static/create_author.js b/library_service/static/page/create_author.js similarity index 100% rename from library_service/static/create_author.js rename to library_service/static/page/create_author.js diff --git a/library_service/static/create_book.js b/library_service/static/page/create_book.js similarity index 100% rename from library_service/static/create_book.js rename to library_service/static/page/create_book.js diff --git a/library_service/static/create_genre.js b/library_service/static/page/create_genre.js similarity index 100% rename from library_service/static/create_genre.js rename to library_service/static/page/create_genre.js diff --git a/library_service/static/edit_author.js b/library_service/static/page/edit_author.js similarity index 100% rename from library_service/static/edit_author.js rename to library_service/static/page/edit_author.js diff --git a/library_service/static/edit_book.js b/library_service/static/page/edit_book.js similarity index 100% rename from library_service/static/edit_book.js rename to library_service/static/page/edit_book.js diff --git a/library_service/static/edit_genre.js b/library_service/static/page/edit_genre.js similarity index 100% rename from library_service/static/edit_genre.js rename to library_service/static/page/edit_genre.js diff --git a/library_service/static/index.js b/library_service/static/page/index.js similarity index 100% rename from library_service/static/index.js rename to library_service/static/page/index.js diff --git a/library_service/static/my_books.js b/library_service/static/page/my_books.js similarity index 88% rename from library_service/static/my_books.js rename to library_service/static/page/my_books.js index c0c8e25..14ceb38 100644 --- a/library_service/static/my_books.js +++ b/library_service/static/page/my_books.js @@ -18,11 +18,10 @@ $(document).ready(() => { try { const data = await Api.get("/api/loans/?page=1&size=100"); allLoans = data.loans; - - // Загружаем информацию о книгах - const bookIds = [...new Set(allLoans.map(loan => loan.book_id))]; + + const bookIds = [...new Set(allLoans.map((loan) => loan.book_id))]; await loadBooks(bookIds); - + renderLoans(); } catch (error) { console.error("Failed to load loans", error); @@ -46,12 +45,12 @@ $(document).ready(() => { function renderLoans() { const reservations = allLoans.filter( - loan => !loan.returned_at && getBookStatus(loan.book_id) === "reserved" + (loan) => !loan.returned_at && getBookStatus(loan.book_id) === "reserved", ); const activeLoans = allLoans.filter( - loan => !loan.returned_at && getBookStatus(loan.book_id) === "borrowed" + (loan) => !loan.returned_at && getBookStatus(loan.book_id) === "borrowed", ); - const returned = allLoans.filter(loan => loan.returned_at !== null); + const returned = allLoans.filter((loan) => loan.returned_at !== null); renderReservations(reservations); renderActiveLoans(activeLoans); @@ -70,7 +69,7 @@ $(document).ready(() => { if (reservations.length === 0) { $container.html( - '
Нет активных бронирований
' + '
Нет активных бронирований
', ); return; } @@ -79,7 +78,9 @@ $(document).ready(() => { const book = booksCache.get(loan.book_id); if (!book) return; - const borrowedDate = new Date(loan.borrowed_at).toLocaleDateString("ru-RU"); + const borrowedDate = new Date(loan.borrowed_at).toLocaleDateString( + "ru-RU", + ); const dueDate = new Date(loan.due_date).toLocaleDateString("ru-RU"); const $card = $(` @@ -90,7 +91,7 @@ $(document).ready(() => { ${Utils.escapeHtml(book.title)}

- Авторы: ${book.authors.map(a => a.name).join(", ") || "Не указаны"} + Авторы: ${book.authors.map((a) => a.name).join(", ") || "Не указаны"}

Дата бронирования: ${borrowedDate}

@@ -130,7 +131,7 @@ $(document).ready(() => { if (activeLoans.length === 0) { $container.html( - '
Нет активных выдач
' + '
Нет активных выдач
', ); return; } @@ -139,7 +140,9 @@ $(document).ready(() => { const book = booksCache.get(loan.book_id); if (!book) return; - const borrowedDate = new Date(loan.borrowed_at).toLocaleDateString("ru-RU"); + const borrowedDate = new Date(loan.borrowed_at).toLocaleDateString( + "ru-RU", + ); const dueDate = new Date(loan.due_date).toLocaleDateString("ru-RU"); const isOverdue = new Date(loan.due_date) < new Date(); @@ -151,7 +154,7 @@ $(document).ready(() => { ${Utils.escapeHtml(book.title)}

- Авторы: ${book.authors.map(a => a.name).join(", ") || "Не указаны"} + Авторы: ${book.authors.map((a) => a.name).join(", ") || "Не указаны"}

Дата выдачи: ${borrowedDate}

@@ -179,7 +182,7 @@ $(document).ready(() => { if (returned.length === 0) { $container.html( - '
Нет возвращенных книг
' + '
Нет возвращенных книг
', ); return; } @@ -188,8 +191,12 @@ $(document).ready(() => { const book = booksCache.get(loan.book_id); if (!book) return; - const borrowedDate = new Date(loan.borrowed_at).toLocaleDateString("ru-RU"); - const returnedDate = new Date(loan.returned_at).toLocaleDateString("ru-RU"); + const borrowedDate = new Date(loan.borrowed_at).toLocaleDateString( + "ru-RU", + ); + const returnedDate = new Date(loan.returned_at).toLocaleDateString( + "ru-RU", + ); const dueDate = new Date(loan.due_date).toLocaleDateString("ru-RU"); const $card = $(` @@ -200,7 +207,7 @@ $(document).ready(() => { ${Utils.escapeHtml(book.title)}

- Авторы: ${book.authors.map(a => a.name).join(", ") || "Не указаны"} + Авторы: ${book.authors.map((a) => a.name).join(", ") || "Не указаны"}

Дата выдачи: ${borrowedDate}

@@ -229,15 +236,14 @@ $(document).ready(() => { try { await Api.delete(`/api/loans/${loanId}`); Utils.showToast("Бронирование отменено", "success"); - - // Удаляем из кэша и перезагружаем - allLoans = allLoans.filter(loan => loan.id !== loanId); + + allLoans = allLoans.filter((loan) => loan.id !== loanId); const book = booksCache.get(bookId); if (book) { book.status = "active"; booksCache.set(bookId, book); } - + renderLoans(); } catch (error) { console.error(error); @@ -245,4 +251,3 @@ $(document).ready(() => { } } }); - diff --git a/library_service/static/page/profile.js b/library_service/static/page/profile.js new file mode 100644 index 0000000..98f61a0 --- /dev/null +++ b/library_service/static/page/profile.js @@ -0,0 +1,387 @@ +$(document).ready(() => { + const token = StorageHelper.get("access_token"); + if (!token) { + window.location.href = "/auth"; + return; + } + + let currentUsername = ""; + let currentRecoveryCodes = []; + + loadProfile(); + + function loadProfile() { + Promise.all([ + Api.get("/api/auth/me"), + Api.get("/api/auth/roles").catch(() => ({ roles: [] })), + Api.get("/api/auth/recovery-codes/status").catch(() => null), + ]) + .then(async ([user, rolesData, recoveryStatus]) => { + document.title = `LiB - ${user.full_name || user.username}`; + currentUsername = user.username; + + await renderProfileHeader(user); + renderInfo(user); + renderRoles(user.roles || [], rolesData.roles || []); + + window.dispatchEvent( + new CustomEvent("update-2fa", { detail: user.is_2fa_enabled }), + ); + + if (recoveryStatus) { + window.dispatchEvent( + new CustomEvent("update-recovery-codes", { + detail: recoveryStatus.remaining, + }), + ); + } + + $("#account-section, #roles-section").removeClass("hidden"); + }) + .catch((error) => { + console.error(error); + Utils.showToast("Ошибка загрузки профиля", "error"); + }); + } + + async function renderProfileHeader(user) { + const avatarUrl = await Utils.getGravatarUrl(user.email); + const displayName = Utils.escapeHtml(user.full_name || user.username); + + $("#profile-card").html(` +
+
+ + ${user.is_verified ? '
' : ""} +
+
+

${displayName}

+

@${Utils.escapeHtml(user.username)}

+ + ${user.is_active ? "Активен" : "Заблокирован"} + +
+
+ `); + } + + function renderInfo(user) { + const fields = [ + { label: "ID пользователя", value: user.id }, + { label: "Email", value: user.email }, + { label: "Полное имя", value: user.full_name || "Не указано" }, + ]; + + const html = fields + .map( + (f) => ` +
+ ${f.label} + ${Utils.escapeHtml(String(f.value))} +
+ `, + ) + .join(""); + + $("#account-info").html(html); + } + + function renderRoles(userRoles, allRoles) { + const $container = $("#roles-container"); + if (userRoles.length === 0) { + $container.html('

Нет ролей

'); + return; + } + + const roleMap = {}; + allRoles.forEach((r) => (roleMap[r.name] = r.description)); + + const html = userRoles + .map( + (role) => ` +
+
${Utils.escapeHtml(role)}
+
${Utils.escapeHtml(roleMap[role] || "")}
+
+ `, + ) + .join(""); + + $container.html(html); + } + + $("#recovery-codes-btn").on("click", function () { + resetRecoveryCodesModal(); + window.dispatchEvent(new CustomEvent("open-recovery-codes-modal")); + loadRecoveryCodesStatus(); + }); + + function resetRecoveryCodesModal() { + $("#recovery-codes-loading").removeClass("hidden"); + $("#recovery-codes-status").addClass("hidden"); + $("#recovery-codes-display").addClass("hidden"); + $("#codes-saved-checkbox").prop("checked", false); + $("#close-recovery-modal-btn").prop("disabled", true); + $("#regenerate-codes-btn") + .prop("disabled", false) + .text("Сгенерировать новые коды"); + currentRecoveryCodes = []; + } + + async function loadRecoveryCodesStatus() { + try { + const status = await Api.get("/api/auth/recovery-codes/status"); + renderRecoveryCodesStatus(status); + } catch (error) { + Utils.showToast( + error.message || "Ошибка загрузки статуса кодов", + "error", + ); + window.dispatchEvent(new CustomEvent("close-recovery-codes-modal")); + } + } + + function renderRecoveryCodesStatus(status) { + const { total, remaining, used_codes, generated_at, should_regenerate } = + status; + + let iconBgClass, iconColorClass, iconSvg; + if (remaining <= 2) { + iconBgClass = "bg-red-100"; + iconColorClass = "text-red-600"; + iconSvg = ``; + } else if (remaining <= 5) { + iconBgClass = "bg-yellow-100"; + iconColorClass = "text-yellow-600"; + iconSvg = ``; + } else { + iconBgClass = "bg-green-100"; + iconColorClass = "text-green-600"; + iconSvg = ``; + } + + $("#status-icon-container") + .removeClass() + .addClass( + `flex items-center justify-center w-12 h-12 mx-auto rounded-full mb-4 ${iconBgClass}`, + ) + .html( + `${iconSvg}`, + ); + + let statusColorClass; + if (remaining <= 2) { + statusColorClass = "text-red-600"; + } else if (remaining <= 5) { + statusColorClass = "text-yellow-600"; + } else { + statusColorClass = "text-green-600"; + } + + $("#codes-status-summary").html(` +

+ Доступно кодов: ${remaining} из ${total} +

+ `); + + const $list = $("#codes-status-list"); + $list.empty(); + + used_codes.forEach((used, index) => { + const codeDisplay = "████-████-████-████"; + const statusClass = used + ? "text-gray-300 line-through" + : "text-green-600"; + const statusIcon = used ? "✗" : "✓"; + const bgClass = used ? "bg-gray-50" : "bg-green-50"; + + $list.append(` +
+ ${index + 1}. ${codeDisplay} + ${statusIcon} +
+ `); + }); + + if (should_regenerate || remaining <= 2) { + let warningText; + if (remaining === 0) { + warningText = + "У вас не осталось резервных кодов! Срочно сгенерируйте новые."; + } else if (remaining <= 2) { + warningText = "Осталось мало кодов. Рекомендуем сгенерировать новые."; + } else { + warningText = "Рекомендуем сгенерировать новые коды для безопасности."; + } + $("#warning-text").text(warningText); + $("#codes-warning").removeClass("hidden"); + } else { + $("#codes-warning").addClass("hidden"); + } + + if (generated_at) { + const date = new Date(generated_at); + $("#codes-generated-at").text(`Сгенерированы: ${date.toLocaleString()}`); + } + + $("#recovery-codes-loading").addClass("hidden"); + $("#recovery-codes-status").removeClass("hidden"); + } + + $("#regenerate-codes-btn").on("click", async function () { + const $btn = $(this); + $btn.prop("disabled", true).text("Генерация..."); + + try { + const response = await Api.post("/api/auth/recovery-codes/regenerate"); + + currentRecoveryCodes = response.codes; + displayNewRecoveryCodes(response.codes, response.generated_at); + + window.dispatchEvent( + new CustomEvent("update-recovery-codes", { + detail: response.codes.length, + }), + ); + + Utils.showToast("Новые коды успешно сгенерированы", "success"); + } catch (error) { + Utils.showToast(error.message || "Ошибка генерации кодов", "error"); + $btn.prop("disabled", false).text("Сгенерировать новые коды"); + } + }); + + function displayNewRecoveryCodes(codes, generatedAt) { + const $list = $("#recovery-codes-list"); + $list.empty(); + + codes.forEach((code, index) => { + $list.append(` +
+ ${index + 1}. ${Utils.escapeHtml(code)} +
+ `); + }); + + if (generatedAt) { + const date = new Date(generatedAt); + $("#recovery-codes-generated-at").text( + `Сгенерированы: ${date.toLocaleString()}`, + ); + } + + $("#recovery-codes-status").addClass("hidden"); + $("#recovery-codes-display").removeClass("hidden"); + } + + $("#codes-saved-checkbox").on("change", function () { + $("#close-recovery-modal-btn").prop("disabled", !this.checked); + }); + + $("#copy-codes-btn").on("click", function () { + if (currentRecoveryCodes.length === 0) return; + + const codesText = currentRecoveryCodes.join("\n"); + navigator.clipboard.writeText(codesText).then(() => { + const $btn = $(this); + const originalHtml = $btn.html(); + $btn.html(` + + + + Скопировано! + `); + setTimeout(() => $btn.html(originalHtml), 2000); + Utils.showToast("Коды скопированы в буфер обмена", "success"); + }); + }); + + $("#download-codes-btn").on("click", function () { + if (currentRecoveryCodes.length === 0) return; + + const username = currentUsername || "user"; + const codesText = `Резервные коды для аккаунта: ${username} +Дата: ${new Date().toLocaleString()} + +${currentRecoveryCodes.map((c, i) => `${i + 1}. ${c}`).join("\n")} + +Храните эти коды в надёжном месте! +Каждый код можно использовать только один раз.`; + + const blob = new Blob([codesText], { type: "text/plain;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `recovery-codes-${username}.txt`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + Utils.showToast("Файл с кодами скачан", "success"); + }); + + $("#close-recovery-modal-btn, #close-status-modal-btn").on( + "click", + function () { + window.dispatchEvent(new CustomEvent("close-recovery-codes-modal")); + }, + ); + + $("#submit-disable-2fa-btn").on("click", async function () { + const $btn = $(this); + const password = $("#disable-2fa-password").val(); + + if (!password) { + Utils.showToast("Введите пароль", "error"); + return; + } + + $btn.prop("disabled", true).text("Отключение..."); + + try { + await Api.post("/api/auth/2fa/disable", { password }); + Utils.showToast("2FA успешно отключена", "success"); + window.dispatchEvent(new CustomEvent("update-2fa", { detail: false })); + window.dispatchEvent(new CustomEvent("close-disable-2fa-modal")); + + $("#disable-2fa-form")[0].reset(); + } catch (error) { + Utils.showToast(error.message || "Ошибка отключения 2FA", "error"); + } finally { + $btn.prop("disabled", false).text("Отключить"); + } + }); + + $("#submit-password-btn").on("click", async function () { + const $btn = $(this); + const newPass = $("#new-password").val(); + const confirm = $("#confirm-password").val(); + + if (newPass !== confirm) { + Utils.showToast("Пароли не совпадают", "error"); + return; + } + + if (newPass.length < 8) { + Utils.showToast("Пароль должен быть минимум 8 символов", "error"); + return; + } + + $btn.prop("disabled", true).text("Сохранение..."); + + try { + await Api.put("/api/auth/me", { password: newPass }); + + Utils.showToast("Пароль успешно изменён", "success"); + + window.dispatchEvent(new CustomEvent("close-password-modal")); + + $("#change-password-form")[0].reset(); + } catch (error) { + Utils.showToast(error.message || "Ошибка смены пароля", "error"); + } finally { + $btn.prop("disabled", false).text("Сменить"); + } + }); +}); diff --git a/library_service/static/users.js b/library_service/static/page/users.js similarity index 99% rename from library_service/static/users.js rename to library_service/static/page/users.js index bf6636e..8299dc2 100644 --- a/library_service/static/users.js +++ b/library_service/static/page/users.js @@ -544,10 +544,6 @@ $(document).ready(() => { updateData.password = password; } - // Note: This uses the /api/auth/me endpoint structure - // For admin editing other users, you might need a different endpoint - // Here we'll simulate by updating local data - Api.put(`/api/auth/me`, updateData) .then((updatedUser) => { const userIndex = users.findIndex((u) => u.id === userId); @@ -601,7 +597,6 @@ $(document).ready(() => { Utils.showToast("Удаление пользователей не поддерживается API", "error"); closeDeleteModal(); - // When API supports deletion: // Api.delete(`/api/auth/users/${userToDelete.id}`) // .then(() => { // users = users.filter(u => u.id !== userToDelete.id); diff --git a/library_service/static/profile.js b/library_service/static/profile.js deleted file mode 100644 index bb81772..0000000 --- a/library_service/static/profile.js +++ /dev/null @@ -1,128 +0,0 @@ -$(document).ready(() => { - const token = StorageHelper.get("access_token"); - if (!token) { - window.location.href = "/auth"; - return; - } - - loadProfile(); - - function loadProfile() { - Promise.all([ - Api.get("/api/auth/me"), - Api.get("/api/auth/roles").catch(() => ({ roles: [] })), - ]) - .then(async ([user, rolesData]) => { - document.title = `LiB - ${user.full_name || user.username}`; - await renderProfileHeader(user); - renderInfo(user); - renderRoles(user.roles || [], rolesData.roles || []); - - $("#account-section, #roles-section").removeClass("hidden"); - }) - .catch((error) => { - console.error(error); - Utils.showToast("Ошибка загрузки профиля", "error"); - }); - } - - async function renderProfileHeader(user) { - const avatarUrl = await Utils.getGravatarUrl(user.email); - const displayName = Utils.escapeHtml(user.full_name || user.username); - - $("#profile-card").html(` -
-
- - ${user.is_verified ? '
' : ""} -
-
-

${displayName}

-

@${Utils.escapeHtml(user.username)}

- - ${user.is_active ? "Активен" : "Заблокирован"} - -
-
- `); - } - - function renderInfo(user) { - const fields = [ - { label: "ID пользователя", value: user.id }, - { label: "Email", value: user.email }, - { label: "Полное имя", value: user.full_name || "Не указано" }, - ]; - - const html = fields - .map( - (f) => ` -
- ${f.label} - ${Utils.escapeHtml(String(f.value))} -
- `, - ) - .join(""); - - $("#account-info").html(html); - } - - function renderRoles(userRoles, allRoles) { - const $container = $("#roles-container"); - if (userRoles.length === 0) { - $container.html('

Нет ролей

'); - return; - } - - const roleMap = {}; - allRoles.forEach((r) => (roleMap[r.name] = r.description)); - - const html = userRoles - .map( - (role) => ` -
-
${Utils.escapeHtml(role)}
-
${Utils.escapeHtml(roleMap[role] || "")}
-
- `, - ) - .join(""); - - $container.html(html); - } - - $("#submit-password-btn").on("click", async function () { - const $btn = $(this); - const newPass = $("#new-password").val(); - const confirm = $("#confirm-password").val(); - - if (newPass !== confirm) { - Utils.showToast("Пароли не совпадают", "error"); - return; - } - - if (newPass.length < 4) { - Utils.showToast("Пароль слишком короткий", "error"); - return; - } - - $btn.prop("disabled", true).text("Меняем..."); - - try { - await Api.put("/api/auth/me", { - password: newPass, - }); - - Utils.showToast("Пароль успешно изменен", "success"); - window.dispatchEvent(new CustomEvent("close-modal")); - - $("#change-password-form")[0].reset(); - } catch (error) { - console.error(error); - Utils.showToast(error.message || "Ошибка смены пароля", "error"); - } finally { - $btn.prop("disabled", false).text("Сменить"); - } - }); -}); diff --git a/library_service/static/styles.css b/library_service/static/styles.css index f6e24e8..714ac41 100644 --- a/library_service/static/styles.css +++ b/library_service/static/styles.css @@ -242,3 +242,29 @@ button:disabled { -webkit-box-orient: vertical; overflow: hidden; } + +footer a { + color: #9ca3af; + text-decoration: none; + transition: all 0.25s ease; + position: relative; +} + +footer a::after { + content: ""; + position: absolute; + bottom: -2px; + left: 0; + width: 0; + height: 1px; + background: #fff; + transition: width 0.25s ease; +} + +footer a:hover { + color: #fff; +} + +footer a:hover::after { + width: 100%; +} diff --git a/library_service/templates/2fa.html b/library_service/templates/2fa.html index 5a4a109..740634d 100644 --- a/library_service/templates/2fa.html +++ b/library_service/templates/2fa.html @@ -1,5 +1,5 @@ -{% extends "base.html" %} {% block title %}LiB - Настройка 2FA{% endblock %} {% -block content %} +{% extends "base.html" %} {% block title %}LiB - Настройка 2FA{% endblock %} +{% block content %}

- Отсканируйте код в Google Authenticator + Отсканируйте код в приложении Аутентификатора

{% endblock %} {% block scripts %} - + {% endblock %} diff --git a/library_service/templates/analytics.html b/library_service/templates/analytics.html index a88c239..401a5f2 100644 --- a/library_service/templates/analytics.html +++ b/library_service/templates/analytics.html @@ -1,11 +1,11 @@ -{% extends "base.html" %} {% block title %}Аналитика - LiB{% endblock %} {% block content %} +{% extends "base.html" %} {% block title %}LiB - Аналитика{% endblock %} +{% block content %}

Аналитика выдач и возвратов

Статистика и графики по выдачам книг

-
@@ -22,7 +22,6 @@
-
@@ -109,7 +108,6 @@
-

Выдачи по дням

@@ -126,7 +124,6 @@
-

Топ книг по выдачам

@@ -137,6 +134,5 @@ {% endblock %} {% block extra_head %} {% endblock %} {% block scripts %} - + {% endblock %} - diff --git a/library_service/templates/auth.html b/library_service/templates/auth.html index 3c93e5b..45c8c07 100644 --- a/library_service/templates/auth.html +++ b/library_service/templates/auth.html @@ -1,323 +1,324 @@ -{% extends "base.html" %} {% block title %}LiB - Авторизация{% endblock %} {% -block content %} +{% extends "base.html" %} {% block title %}LiB - Авторизация{% endblock %} +{% block content %}
- -
+
-
- - -
-
- -
- +
+ + -
+
+ +
+ + +
+
+
+ +
-
- - Забыли пароль? + + - - - +
+
+ + {% endblock %} {% block scripts %} - + {% endblock %} diff --git a/library_service/templates/author.html b/library_service/templates/author.html index ec508a9..fef69fb 100644 --- a/library_service/templates/author.html +++ b/library_service/templates/author.html @@ -1,4 +1,5 @@ -{% extends "base.html" %} {% block content %} +{% extends "base.html" %} {% block title %}LiB - Автор{% endblock %} +{% block content %}
@@ -115,5 +116,5 @@
{% endblock %} {% block scripts %} - + {% endblock %} diff --git a/library_service/templates/authors.html b/library_service/templates/authors.html index 4efee0b..be68dae 100644 --- a/library_service/templates/authors.html +++ b/library_service/templates/authors.html @@ -1,4 +1,5 @@ -{% extends "base.html" %} {% block content %} +{% extends "base.html" %} {% block title %}LiB - Авторы{% endblock %} +{% block content %}
{% endblock %} {% block scripts %} - + {% endblock %} diff --git a/library_service/templates/base.html b/library_service/templates/base.html index 183226d..502c17d 100644 --- a/library_service/templates/base.html +++ b/library_service/templates/base.html @@ -238,7 +238,9 @@
-

© 2025 LiB Library. All rights reserved.

+

© 2026 LiB Library. Разработано в рамках дипломного проекта. + Код открыт под лицензией MIT. +

{% block scripts %}{% endblock %} diff --git a/library_service/templates/book.html b/library_service/templates/book.html index 0e8c4ea..a3b16af 100644 --- a/library_service/templates/book.html +++ b/library_service/templates/book.html @@ -1,4 +1,5 @@ -{% extends "base.html" %} {% block content %} +{% extends "base.html" %} {% block title %}LiB - Книга{% endblock %} +{% block content %}
@@ -143,7 +144,6 @@
- - {% endblock %} {% block scripts %} - + {% endblock %} diff --git a/library_service/templates/books.html b/library_service/templates/books.html index c262a8b..5a36bae 100644 --- a/library_service/templates/books.html +++ b/library_service/templates/books.html @@ -1,4 +1,5 @@ -{% extends "base.html" %} {% block content %} +{% extends "base.html" %} {% block title %}LiB - Книги{% endblock %} +{% block content %}