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
+41 -4
View File
@@ -17,6 +17,9 @@ from sqlalchemy import text
from sqlalchemy.orm import Session
from backend.audit import audit_log, visible_unit_ids
from backend.logging_setup import get_logger
from backend.password_policy import record_password_change, validate_new_password
from backend.purge_logs import _retention_days, purge_logs
from backend.database import get_db
from backend.models import MchsUnit, Role, User, UserRole
from backend.routers.auth import (
@@ -139,6 +142,13 @@ def create_user(
) -> dict[str, Any]:
if len(payload.password) < 8:
raise HTTPException(status_code=400, detail='Минимальная длина пароля: 8 символов')
# B23: сложность проверяется и на временный пароль админа (история пуста —
# новый пользователь, проверка переиспользования не применима)
probe = User(username=payload.username, email=payload.email)
try:
validate_new_password(db, probe, payload.password)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
if db.query(User).filter(User.username == payload.username).first():
raise HTTPException(status_code=400, detail='Пользователь с таким именем уже существует')
if db.query(User).filter(User.email == payload.email).first():
@@ -234,10 +244,18 @@ def update_user(
changes['is_active'] = (target.is_active, payload.is_active)
target.is_active = payload.is_active
if payload.reset_password:
if len(payload.reset_password) < 8:
raise HTTPException(status_code=400, detail='Минимальная длина пароля: 8 символов')
# B23: сброс админом тоже под полной политикой
probe = User(username=target.username, email=target.email, id=target.id)
try:
validate_new_password(db, probe, payload.reset_password,
current_hash=target.hashed_password)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
changes['password'] = ('***', '***')
target.hashed_password = get_password_hash(payload.reset_password)
new_hash = get_password_hash(payload.reset_password)
record_password_change(db, target, new_hash,
previous_hash=target.hashed_password)
target.hashed_password = new_hash
target.must_change_password = True
if not changes:
@@ -285,4 +303,23 @@ def list_audit(
'changes': [{'field': ch.field_name, 'old': ch.old_value, 'new': ch.new_value}
for ch in changes],
})
return {'items': items, 'total': q.count(), 'retention_days': retention}
return {'items': items, 'total': q.count(), 'retention_days': retention}
@router.post('/maintenance/purge-logs')
def run_purge_logs(
request: Request,
current_user: User = Depends(require_permission('manage_security')),
db: Session = Depends(get_db),
) -> dict[str, Any]:
"""B23: реальное удаление журналов старше audit_retention_days.
Ручной запуск из админки; регулярный — cron `python -m backend.purge_logs`.
Удаляет audit_events (+changes каскадом) и auth_events.
"""
result = purge_logs(db)
audit_log(db, current_user, 'purge_logs', object_type='maintenance',
request=request, details=result)
log = get_logger('admin')
log.info('purge_logs by %s: %s', getattr(current_user, 'username', '—'), result)
return {'purged': result, 'retention_days': _retention_days(db)}
+14 -7
View File
@@ -17,6 +17,7 @@ from sqlalchemy.orm import Session
from backend.database import get_db
from backend.logging_setup import get_logger
from backend.models import AuthEvent, SecuritySetting, User, UserSession
from backend.password_policy import enforce_expiry, record_password_change, validate_new_password
log = get_logger('auth')
@@ -308,6 +309,9 @@ async def get_current_user(
headers={'WWW-Authenticate': 'Bearer'},
)
# B23: срок действия пароля (security_settings) — исполняется, а не декларируется
enforce_expiry(db, user)
return user
@@ -407,15 +411,18 @@ def change_password(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> dict[str, str]:
min_len = int(_get_setting(db, 'password_min_length', '8'))
if not verify_password(payload.current_password, current_user.hashed_password):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Текущий пароль неверен')
if len(payload.new_password) < min_len:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Минимальная длина пароля: {min_len} символов',
)
current_user.hashed_password = get_password_hash(payload.new_password)
# B23: полная политика (длина, сложность, история) вместо голой длины
try:
validate_new_password(db, current_user, payload.new_password,
current_hash=current_user.hashed_password)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
new_hash = get_password_hash(payload.new_password)
record_password_change(db, current_user, new_hash,
previous_hash=current_user.hashed_password)
current_user.hashed_password = new_hash
current_user.must_change_password = False
current_user.updated_at = datetime.now(timezone.utc)
# Отзываем все сессии после смены пароля (кроме текущей — её пересоздадим)