Улучшение безопасности

This commit is contained in:
2026-01-19 23:22:29 +03:00
parent 758e0fc9e6
commit d6ecd4066f
59 changed files with 2712 additions and 1010 deletions
+231 -27
View File
@@ -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))
+34 -12
View File
@@ -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()
+49 -56
View File
@@ -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
+1 -1
View File
@@ -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")