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

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
+109
View File
@@ -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",
]
+257
View File
@@ -0,0 +1,257 @@
"""Модуль основного функционала авторизации и аутентификации"""
import os
from datetime import datetime, timedelta, timezone
from typing import Annotated
from uuid import uuid4
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jose import jwt, JWTError, ExpiredSignatureError
from passlib.context import CryptContext
from sqlmodel import Session, select
from library_service.models.db import Role, User
from library_service.models.dto import TokenData
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", "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 схема
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/token")
# Проверка секретного ключа
if not SECRET_KEY:
raise RuntimeError("SECRET_KEY environment variable is required")
# Хэширование паролей
pwd_context = CryptContext(
schemes=["argon2"],
deprecated="auto",
argon2__type=ARGON2_TYPE,
argon2__time_cost=ARGON2_TIME_COST,
argon2__memory_cost=ARGON2_MEMORY_COST,
argon2__parallelism=ARGON2_PARALLELISM,
argon2__salt_len=ARGON2_SALT_LENGTH,
argon2__hash_len=ARGON2_HASH_LENGTH,
)
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""Проверяет пароль по его хешу"""
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
"""Хэширует пароль"""
return pwd_context.hash(password)
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,
"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)
return _create_token(data, delta, "access")
def create_refresh_token(data: dict) -> str:
"""Создает JWT refresh токен"""
return _create_token(data, timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS), "refresh")
def decode_token(
token: str,
expected_type: str = "access",
allow_partial: bool = False,
) -> TokenData:
"""Декодирует и проверяет JWT токен"""
token_error = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
username: str | None = payload.get("sub")
user_id: int | None = payload.get("user_id")
token_type: str | None = payload.get("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, is_partial=is_partial)
except ExpiredSignatureError:
token_error.detail = "Token expired"
raise token_error
except JWTError:
token_error.detail = "Could not validate credentials"
raise token_error
def authenticate_user(session: Session, username: str, password: str) -> User | None:
"""Аутентифицирует пользователя по имени и паролю"""
statement = select(User).where(User.username == username)
user = session.exec(statement).first()
if not user or not verify_password(password, user.hashed_password):
return None
return user
def get_current_user(
token: Annotated[str, Depends(oauth2_scheme)],
session: Session = Depends(get_session),
) -> User:
"""Возвращает текущего авторизованного пользователя"""
token_data = decode_token(token)
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 get_current_active_user(
current_user: Annotated[User, Depends(get_current_user)],
) -> User:
"""Проверяет активность пользователя и возвращает его"""
if not current_user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Inactive 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 для проверки наличия определенной роли"""
def role_checker(current_user: User = Depends(get_current_active_user)) -> User:
user_roles = [role.name for role in current_user.roles]
if role_name not in user_roles:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Role '{role_name}' required",
)
return current_user
return role_checker
def require_any_role(allowed_roles: list[str]):
"""Создает dependency для проверки наличия хотя бы одной из ролей"""
def role_checker(current_user: User = Depends(get_current_active_user)) -> User:
user_roles = {role.name for role in current_user.roles}
if not (user_roles & set(allowed_roles)):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Requires one of roles: {allowed_roles}",
)
return current_user
return role_checker
# Создание dependencies
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"]))]
def is_user_staff(user: User) -> bool:
"""Проверяет, является ли пользователь сотрудником (admin или librarian)"""
roles = {role.name for role in user.roles}
return bool(roles & {"admin", "librarian"})
def is_user_admin(user: User) -> bool:
"""Проверяет, является ли пользователь администратором"""
roles = {role.name for role in user.roles}
return "admin" in roles
+149
View File
@@ -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,
}
+95
View File
@@ -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"])
+78
View File
@@ -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,
}