Files
ipam_control/app/security.py
T
ayurishchevandClaude Opus 5.5 13e17fbb47 Задачи 011-024: доработки по ревью кодовой базы и исправление находок
Ревью кодовой базы (docs/reviews/2026-09-26-codebase-review.md) и планы по каждой находке:
011 IP уникален в VRF и хранится в самом узком префиксе (addresses.vrf_id, составной FK
    с каскадом при переносе VRF, миграция 0007 с остановкой на дублях).
012 Ограничение попыток входа (login_attempts, 429 + Retry-After), выравнивание времени
    ответа, журнал без вытеснения анонимными событиями (миграция 0006).
013 Границы пагинации: отрицательные/чрезмерные limit/offset дают 422 вместо 500.
014 Экран адресов: страница свободных адресов арифметикой, пагинация в SQL.
015 Запрет адреса сети/broadcast, загрузка не выше 100 %.
016 Роль по умолчанию — viewer.
017 Проверка JWT_SECRET/ADMIN_PASSWORD при старте.
018 null в PATCH очищает текстовые поля; нейтральный текст конфликта БД.
019 Пакетная загрузка в списках вместо N+1.
020 Автоназначение адреса вне вложенных префиксов, с блокировкой префикса.
021 Advisory-lock при снятии прав администратора, уникальный lower(username) (миграция 0008).
022 Контейнер не от root, healthcheck, блокировка миграций, requirements.lock.
023 Экранирование LIKE, журнал отказов очистки, заголовки безопасности, учёт force-удаления,
    отзыв токенов при смене пароля (claim pv, миграция 0005).
024 Исправление находок ревью 011-023 (docs/reviews/2026-09-26-changes-011-023-review.md):
    сериализация попыток входа, запрет переноса адресов в адрес сети/broadcast, журнал входов,
    запрет смены своего пароля через PATCH, валидация PATCH устройства, обновлён тест токенов.

Тесты: 14 passed. Документация: README.md, docs/changes/011-024, docs/reviews.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 21:33:50 +03:00

69 lines
2.9 KiB
Python

from datetime import datetime, timedelta, timezone
import jwt
from argon2 import PasswordHasher
from argon2.exceptions import VerificationError
from fastapi import Depends, HTTPException
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.config import settings
from app.db import get_db
from app.models import Role, User
_ph = PasswordHasher()
_bearer = HTTPBearer(auto_error=False)
def hash_password(password: str) -> str:
return _ph.hash(password)
def verify_password(password: str, hashed: str) -> bool:
try:
return _ph.verify(hashed, password)
except VerificationError: # несовпадение пароля или повреждённый хэш — не 500, а 401/403
return False
_EPOCH = datetime(1970, 1, 1, tzinfo=timezone.utc)
def _password_version(user: User) -> int:
"""Версия пароля в токене: смена пароля меняет её, и выданные ранее токены перестают действовать (без зависимости от точности часов).
Целочисленно (изменение 024, находка №7): без float, хотя результат и был детерминирован."""
changed = user.password_changed_at
return (changed - _EPOCH) // timedelta(microseconds=1) if changed else 0
def create_token(user: User) -> str:
now = datetime.now(timezone.utc)
exp = now + timedelta(minutes=settings.jwt_ttl_minutes)
# iat — информационное поле (не проверяется при разборе токена); отзыв выданных токенов работает по pv (версия пароля)
return jwt.encode({"sub": user.username, "iat": now, "exp": exp, "pv": _password_version(user)}, settings.jwt_secret, algorithm="HS256")
def current_user(
cred: HTTPAuthorizationCredentials | None = Depends(_bearer), db: Session = Depends(get_db)
) -> User:
if cred is None:
raise HTTPException(401, "Требуется авторизация")
try:
payload = jwt.decode(cred.credentials, settings.jwt_secret, algorithms=["HS256"])
username = payload["sub"]
except (jwt.PyJWTError, KeyError):
raise HTTPException(401, "Недействительный токен")
user = db.scalar(select(User).where(User.username == username, User.is_active))
if user is None:
raise HTTPException(401, "Пользователь не найден")
if payload.get("pv", 0) != _password_version(user):
raise HTTPException(401, "Пароль изменён, войдите заново") # токен выдан до смены пароля
return user
def admin_user(user: User = Depends(current_user)) -> User:
if user.role != Role.admin:
raise HTTPException(403, "Недостаточно прав")
return user