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