Files
ipam_control/app/models.py
T
ayurishchevandClaude Opus 5.5 d3495fb077 Задача 037: двухфакторная аутентификация TOTP по выбору пользователя
Пользователь сам включает TOTP (QR, приложение-аутентификатор); 10 одноразовых кодов
восстановления; superadmin сбрасывает 2FA другому пользователю. Вход в два шага:
/auth/login → mfa_token, /auth/login/2fa — под теми же лимитами и advisory-lock, что пароль.
Секрет шифруется Fernet-ключом TOTP_ENC_KEY, защита от повторного использования кода
(totp_last_step), лимиты перебора кода при отключении. Миграция 0015, зависимости pyotp,
segno, cryptography. UI: второй шаг входа, диалоги 2FA в меню логина, бейдж и сброс
в «Пользователях»; submitDialog не закрывает окно, открытое следующим шагом цепочки.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 16:57:14 +03:00

229 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import enum
import uuid
from datetime import datetime
from sqlalchemy import (
BigInteger, Boolean, CheckConstraint, DateTime, Enum, ForeignKey, ForeignKeyConstraint, Index, Integer, String, Text,
UniqueConstraint, func, text,
)
from sqlalchemy.dialects.postgresql import CIDR, INET, JSONB, UUID
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
class Base(DeclarativeBase):
pass
class PrefixStatus(str, enum.Enum):
active = "active"
reserved = "reserved"
deprecated = "deprecated"
class AddressStatus(str, enum.Enum):
assigned = "assigned"
reserved = "reserved"
deprecated = "deprecated"
class Role(str, enum.Enum):
superadmin = "superadmin"
admin = "admin"
viewer = "viewer"
class Organization(Base):
__tablename__ = "organizations"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(255), unique=True)
short_name: Mapped[str] = mapped_column(String(100), default="")
inn: Mapped[str] = mapped_column(String(12), unique=True)
address: Mapped[str] = mapped_column(String(500), default="")
contact_person: Mapped[str] = mapped_column(String(255), default="")
phone: Mapped[str] = mapped_column(String(50), default="")
email: Mapped[str] = mapped_column(String(255), default="")
note: Mapped[str] = mapped_column(Text, default="")
class Vrf(Base):
__tablename__ = "vrfs"
# (id, organization_id) — цель составного FK префиксов: VRF префикса всегда из его организации
__table_args__ = (UniqueConstraint("id", "organization_id", name="uq_vrfs_id_organization_id"),)
id: Mapped[int] = mapped_column(primary_key=True)
organization_id: Mapped[int] = mapped_column(ForeignKey("organizations.id"))
name: Mapped[str] = mapped_column(String(100))
route_target: Mapped[str] = mapped_column(String(50), default="")
note: Mapped[str] = mapped_column(Text, default="")
Index("uq_vrfs_org_lower_name", Vrf.organization_id, func.lower(Vrf.name), unique=True) # имя VRF уникально в организации без учёта регистра
class Prefix(Base):
__tablename__ = "prefixes"
__table_args__ = (
UniqueConstraint("vrf_id", "prefix"),
UniqueConstraint("id", "vrf_id", name="uq_prefixes_id_vrf_id"), # цель составного FK адресов
ForeignKeyConstraint(["vrf_id", "organization_id"], ["vrfs.id", "vrfs.organization_id"], name="fk_prefixes_vrf_org"),
)
id: Mapped[int] = mapped_column(primary_key=True)
organization_id: Mapped[int] = mapped_column(ForeignKey("organizations.id"), index=True)
vrf_id: Mapped[int] = mapped_column(ForeignKey("vrfs.id"), index=True)
prefix: Mapped[str] = mapped_column(CIDR)
description: Mapped[str] = mapped_column(String(500), default="")
status: Mapped[PrefixStatus] = mapped_column(Enum(PrefixStatus, name="prefix_status"), default=PrefixStatus.active)
parent_id: Mapped[int | None] = mapped_column(ForeignKey("prefixes.id", ondelete="SET NULL"))
is_pool: Mapped[bool] = mapped_column(Boolean, default=False)
note: Mapped[str] = mapped_column(Text, default="")
vrf: Mapped[Vrf] = relationship(primaryjoin="Prefix.vrf_id == Vrf.id", foreign_keys=[vrf_id])
class Isp(Base):
__tablename__ = "isps"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(255))
organization_id: Mapped[int] = mapped_column(ForeignKey("organizations.id"), index=True)
contract_number: Mapped[str] = mapped_column(String(100), default="")
hotline: Mapped[str] = mapped_column(String(50), default="")
note: Mapped[str] = mapped_column(Text, default="")
networks: Mapped[list["IspNetwork"]] = relationship(cascade="all, delete-orphan", order_by="IspNetwork.id")
class IspNetwork(Base):
__tablename__ = "isp_networks"
id: Mapped[int] = mapped_column(primary_key=True)
isp_id: Mapped[int] = mapped_column(ForeignKey("isps.id", ondelete="CASCADE"), index=True)
cidr: Mapped[str] = mapped_column(CIDR)
class DeviceType(Base):
__tablename__ = "device_types"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(100), unique=True)
is_default: Mapped[bool] = mapped_column(Boolean, default=False)
class Device(Base):
__tablename__ = "devices"
__table_args__ = (UniqueConstraint("organization_id", "name"),)
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(255))
device_type_id: Mapped[int] = mapped_column(ForeignKey("device_types.id"))
organization_id: Mapped[int] = mapped_column(ForeignKey("organizations.id"), index=True)
mac: Mapped[str] = mapped_column(String(17), default="")
note: Mapped[str] = mapped_column(Text, default="")
class Address(Base):
__tablename__ = "addresses"
# vrf_id дублирует VRF префикса (составной FK, обновляется каскадом при переносе): так БД гарантирует уникальность IP в VRF
__table_args__ = (
UniqueConstraint("prefix_id", "address"),
UniqueConstraint("vrf_id", "address", name="uq_addresses_vrf_address"),
ForeignKeyConstraint(["prefix_id", "vrf_id"], ["prefixes.id", "prefixes.vrf_id"], name="fk_addresses_prefix_vrf", ondelete="CASCADE", onupdate="CASCADE"),
)
id: Mapped[int] = mapped_column(primary_key=True)
prefix_id: Mapped[int] = mapped_column(index=True)
vrf_id: Mapped[int] = mapped_column()
address: Mapped[str] = mapped_column(INET)
status: Mapped[AddressStatus] = mapped_column(Enum(AddressStatus, name="address_status"), default=AddressStatus.assigned)
dns_name: Mapped[str] = mapped_column(String(255), default="")
description: Mapped[str] = mapped_column(String(500), default="")
device_id: Mapped[int | None] = mapped_column(ForeignKey("devices.id", ondelete="SET NULL"), index=True)
note: Mapped[str] = mapped_column(Text, default="")
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
class User(Base):
__tablename__ = "users"
__table_args__ = (
# изменение 032: superadmin без организации, admin/viewer с организацией или неактивны
CheckConstraint("is_active = false OR (role = 'superadmin') = (organization_id IS NULL)", name="ck_users_role_org_scope"), # изменение 033, находка №13
)
id: Mapped[int] = mapped_column(primary_key=True)
username: Mapped[str] = mapped_column(String(100), unique=True)
password_hash: Mapped[str] = mapped_column(String(255))
role: Mapped[Role] = mapped_column(Enum(Role, name="user_role"), default=Role.viewer)
organization_id: Mapped[int | None] = mapped_column(ForeignKey("organizations.id"), index=True) # изменение 032: NK для admin/viewer, NULL для superadmin
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
password_changed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) # токены с iat раньше — недействительны
last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) # изменение 035: время последнего успешного входа
last_login_ip: Mapped[str | None] = mapped_column(INET) # изменение 035: IP последнего успешного входа
# изменение 037: двухфакторная аутентификация TOTP, по выбору пользователя
totp_secret_enc: Mapped[str | None] = mapped_column(Text) # зашифрованный (Fernet, TOTP_ENC_KEY) подтверждённый секрет
totp_pending_enc: Mapped[str | None] = mapped_column(Text) # секрет на время настройки, до подтверждения кодом
totp_enabled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) # NULL — 2FA выключена
totp_last_step: Mapped[int | None] = mapped_column(BigInteger) # защита от повторного использования кода: шаг должен строго возрастать
@property
def totp_enabled(self) -> bool: # изменение 037: вычисляемое поле для UserOut (from_attributes читает его как обычный атрибут)
return self.totp_enabled_at is not None
Index("uq_users_lower_username", func.lower(User.username), unique=True) # логин уникален без учёта регистра
class AuditLog(Base):
__tablename__ = "audit_log"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
ts: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True)
username: Mapped[str] = mapped_column(String(100))
entity_type: Mapped[str] = mapped_column(String(50), index=True)
entity_id: Mapped[int | None] = mapped_column(Integer)
entity_label: Mapped[str] = mapped_column(String(255))
action: Mapped[str] = mapped_column(String(20))
diff: Mapped[dict | None] = mapped_column(JSONB)
uid: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), server_default=text("gen_random_uuid()"), unique=True)
message: Mapped[str] = mapped_column(Text, default="", server_default="")
client_ip: Mapped[str | None] = mapped_column(INET, index=True) # IP клиента запроса; NULL для системных событий
meta: Mapped[dict | None] = mapped_column(JSONB) # user_agent, method, path, request_id
organization_id: Mapped[int | None] = mapped_column(ForeignKey("organizations.id", ondelete="SET NULL"), index=True) # изменение 032: для событий по сущностям организации, NULL для системных; ondelete — изменение 033, находка №1
__table_args__ = (Index("ix_audit_log_entity_type_action", "entity_type", "action"),)
class AppSetting(Base):
__tablename__ = "app_settings"
key: Mapped[str] = mapped_column(String(50), primary_key=True)
value: Mapped[dict] = mapped_column(JSONB)
class LoginAttempt(Base):
"""Неудачные попытки входа (для ограничения перебора паролей); записи старше суток удаляет ротация."""
__tablename__ = "login_attempts"
id: Mapped[int] = mapped_column(primary_key=True)
ts: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
client_ip: Mapped[str | None] = mapped_column(INET)
username: Mapped[str] = mapped_column(String(100)) # в нижнем регистре
__table_args__ = (Index("ix_login_attempts_ip_ts", "client_ip", "ts"), Index("ix_login_attempts_user_ts", "username", "ts"))
class KnownLogin(Base):
"""IP, с которых логин уже успешно входил (last_seen обновляется при каждом входе); запись не старше
KNOWN_IP_DAYS исключает логин из общей блокировки «по логину со всех IP» (изменение 026, находка №2).
Записи старше срока удаляет ротация."""
__tablename__ = "known_logins"
username: Mapped[str] = mapped_column(String(100), primary_key=True) # в нижнем регистре, как в login_attempts
client_ip: Mapped[str] = mapped_column(INET, primary_key=True)
last_seen: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class ClearAttempt(Base):
"""Неудачные попытки подтверждения пароля при очистке журнала (для блокировки)."""
__tablename__ = "clear_attempts"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
ts: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class RecoveryCode(Base):
"""Коды восстановления 2FA (изменение 037): выдаются 10 штук при включении, каждый гасится один раз.
Хэш — sha256 (не argon2): код высокоэнтропийный (10 символов base32), медленный хэш не нужен."""
__tablename__ = "recovery_codes"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
code_hash: Mapped[str] = mapped_column(String(64))
used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))