mirror of
https://github.com/wowlikon/LiB.git
synced 2026-02-04 04:31:09 +00:00
Улучшение безопасности
This commit is contained in:
@@ -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"}
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
returned_at: datetime | None = Field(default=None)
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user