B23: политика паролей исполняется + ретенция журналов по-настоящему

- backend/password_policy.py: validate_new_password (длина, сложность
  буквы+цифры, сверка с текущим и историей), record_password_change
  (хранит ПРЕДЫДУШИЙ хэш, обрезка до password_history_count),
  password_expired + enforce_expiry (пометка must_change_password)
- users.password_changed_at + таблица password_history (010_b23_policy)
- change-password и админ-сброс теперь под полной политикой (сложность
  и переиспользование вместо голой длины); get_current_user проверяет
  срок действия каждого пароля при запросе
- backend/purge_logs.py: реальный DELETE просроченных audit_events
  (+changes) и auth_events; POST /admin/users/maintenance/purge-logs
  (manage_security) + CLI python -m backend.purge_logs для cron
- тесты: +19 (258 passed) — валидация/история/срок, purge/идемпотентность/403
This commit is contained in:
2026-09-25 09:56:26 +03:00
parent d5a270f3af
commit 2222b7ad5b
7 changed files with 585 additions and 11 deletions
+129
View File
@@ -0,0 +1,129 @@
"""B23: исполняемая политика паролей (security_settings).
До сих пор security_settings были декларативными: срок действия и история
пароля показывались в настройках, но код проверял только минимальную длину.
Этот модуль — единственная точка валидации и записи пароля.
Правила (ключи security_settings):
password_min_length 8
password_require_complexity true (буквы + цифры)
password_expiry_days 90 (0 = не проверять срок)
password_history_count 5 (0 = не проверять переиспользование)
Важно: проверка срока действия выполняется при КАЖДОМ защищённом запросе
(get_current_user) — истёкший пароль переводит пользователя в
must_change_password (запросы к /auth/* работают, прочее API → 403 с
подсказкой сменить пароль). Так сессии не ломаются внезапно, но политика
исполняется.
"""
from __future__ import annotations
import re
from datetime import datetime, timedelta, timezone
from sqlalchemy.orm import Session
from backend.logging_setup import get_logger
from backend.models import PasswordHistoryEntry, SecuritySetting, User
log = get_logger('auth')
_COMPLEXITY_DIGIT_RE = re.compile(r'[0-9]')
_COMPLEXITY_LETTER_RE = re.compile(r'[^\W\d_]', re.UNICODE)
def get_setting(db: Session, key: str, default: str) -> str:
row = db.query(SecuritySetting).filter(SecuritySetting.key == key).first()
return row.value if row else default
def _aware(dt: datetime) -> datetime:
if dt.tzinfo is None:
return dt.replace(tzinfo=timezone.utc)
return dt
def validate_new_password(db: Session, user: User, new_password: str,
*, current_hash: str | None = None) -> None:
"""Бросает ValueError с текстом на русском, если пароль нарушает политику.
Проверяет: длину, сложность (буквы+цифры), совпадение с текущим паролем
и переиспользование (последние password_history_count записей истории).
Вызывать ДО записи нового хэша.
"""
min_len = int(get_setting(db, 'password_min_length', '8'))
need_complexity = get_setting(db, 'password_require_complexity', 'true').strip().lower() in {'1', 'true', 'yes', 'on'}
history_count = int(get_setting(db, 'password_history_count', '5'))
if len(new_password) < min_len:
raise ValueError(f'Минимальная длина пароля: {min_len} символов')
if need_complexity:
if not _COMPLEXITY_DIGIT_RE.search(new_password) or not _COMPLEXITY_LETTER_RE.search(new_password):
raise ValueError('Пароль должен содержать буквы и цифры')
# Текущий пароль считается частью истории (нельзя «сменить на тот же»)
candidates: list[str] = []
if current_hash:
candidates.append(current_hash)
if history_count > 0:
rows = (db.query(PasswordHistoryEntry)
.filter(PasswordHistoryEntry.user_id == user.id)
.order_by(PasswordHistoryEntry.created_at.desc())
.limit(history_count).all())
candidates.extend(r.hashed_password for r in rows)
from backend.routers.auth import verify_password
for old_hash in candidates:
if old_hash and verify_password(new_password, old_hash):
raise ValueError('Новый пароль совпадает с ранее использованным')
def record_password_change(db: Session, user: User, new_hash: str,
*, previous_hash: str | None = None) -> None:
"""Записать смену пароля: история + password_changed_at + обрезка истории.
В историю кладётся ПРЕДЫДУЩИЙ хэш (previous_hash — хэш, который заменяют),
чтобы его нельзя было вернуть, пока он не выпал из password_history_count
последних записей. Обрезка хранит ровно password_history_count записей.
"""
history_count = int(get_setting(db, 'password_history_count', '5'))
old_hash = previous_hash if previous_hash is not None else user.hashed_password
if old_hash:
db.add(PasswordHistoryEntry(user_id=user.id, hashed_password=old_hash))
user.password_changed_at = datetime.now(timezone.utc)
if history_count > 0:
keep = (db.query(PasswordHistoryEntry.id)
.filter(PasswordHistoryEntry.user_id == user.id)
.order_by(PasswordHistoryEntry.created_at.desc(),
PasswordHistoryEntry.id.desc())
.limit(history_count).all())
keep_ids = [row[0] for row in keep]
if keep_ids:
(db.query(PasswordHistoryEntry)
.filter(PasswordHistoryEntry.user_id == user.id,
PasswordHistoryEntry.id.notin_(keep_ids))
.delete(synchronize_session=False))
def password_expired(db: Session, user: User) -> bool:
"""True, если срок действия пароля истёк (password_expiry_days > 0)."""
expiry_days = int(get_setting(db, 'password_expiry_days', '90'))
if expiry_days <= 0:
return False
changed_at = getattr(user, 'password_changed_at', None)
if changed_at is None:
# Миграция поставила NOW() — но если записи нет (создан до B23),
# считаем от updated_at, чтобы не выгонять всех разом.
changed_at = getattr(user, 'updated_at', None)
if changed_at is None:
return False
return _aware(changed_at) < datetime.now(timezone.utc) - timedelta(days=expiry_days)
def enforce_expiry(db: Session, user: User) -> None:
"""Отметить must_change_password, если срок истёк (вызывается на каждом запросе)."""
if not getattr(user, 'must_change_password', False) and password_expired(db, user):
user.must_change_password = True
log.warning('password_expired user=%s — пароль старше %s дн.',
getattr(user, 'username', '—'),
get_setting(db, 'password_expiry_days', '90'))
db.commit()