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:
@@ -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()
|
||||
Reference in New Issue
Block a user