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,40 @@
|
||||
"""B23: исполнение политики паролей + реальная ретенция журналов.
|
||||
|
||||
Revises: 009_e3_operations
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql as pg
|
||||
|
||||
revision = '010_b23_policy'
|
||||
down_revision = '009_e3_operations'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Парольная политика: когда пароль был сменён в последний раз
|
||||
op.add_column(
|
||||
'users',
|
||||
sa.Column('password_changed_at', sa.DateTime(timezone=True),
|
||||
server_default=sa.text('NOW()'), nullable=True),
|
||||
)
|
||||
# История паролей (проверка переиспользования, password_history_count)
|
||||
op.create_table(
|
||||
'password_history',
|
||||
sa.Column('id', pg.UUID(as_uuid=True), primary_key=True,
|
||||
server_default=sa.text('gen_random_uuid()')),
|
||||
sa.Column('user_id', pg.UUID(as_uuid=True),
|
||||
sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False),
|
||||
sa.Column('hashed_password', sa.String(255), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('NOW()')),
|
||||
)
|
||||
op.create_index('idx_password_history_user', 'password_history', ['user_id', 'created_at'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index('idx_password_history_user', table_name='password_history')
|
||||
op.drop_table('password_history')
|
||||
op.drop_column('users', 'password_changed_at')
|
||||
@@ -33,6 +33,8 @@ class User(Base):
|
||||
must_change_password = Column(Boolean, default=False)
|
||||
created_by = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"))
|
||||
updated_at = Column(DateTime, server_default=func.now())
|
||||
# B23: политика паролей (expiry/history исполняется в routers/auth.py)
|
||||
password_changed_at = Column(DateTime(timezone=True))
|
||||
|
||||
|
||||
class Case(Base):
|
||||
@@ -335,6 +337,21 @@ class SecuritySetting(Base):
|
||||
key = Column(String(50), primary_key=True)
|
||||
value = Column(String(255), nullable=False)
|
||||
|
||||
|
||||
class PasswordHistoryEntry(Base):
|
||||
"""B23: история паролей (bcrypt-хэши). Записывается при каждой смене,
|
||||
проверяется против password_history_count последних записей."""
|
||||
__tablename__ = "password_history"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
hashed_password = Column(String(255), nullable=False)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
__table_args__ = (
|
||||
sa.Index("idx_password_history_user", "user_id", "created_at"),
|
||||
)
|
||||
|
||||
# ==================== B21/E2: аудит действий ====================
|
||||
|
||||
class SearchOperation(Base):
|
||||
|
||||
@@ -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()
|
||||
@@ -0,0 +1,96 @@
|
||||
"""B23: ретенция журналов — реальное удаление, а не фильтр при чтении.
|
||||
|
||||
До сих пор «ретенция 90 дней» из security_settings реализовывалась как
|
||||
фильтр в GET /admin/users/audit — записи копились в БД вечно. Этот модуль
|
||||
удаляет просроченное по-настоящему (DELETE), охватывая:
|
||||
|
||||
audit_events (+ audit_changes каскадно) — по audit_retention_days
|
||||
auth_events — по audit_retention_days
|
||||
|
||||
Запуск: POST /api/v1/admin/maintenance/purge-logs (право manage_security)
|
||||
или вручную:
|
||||
docker exec vector-backend python -m backend.purge_logs
|
||||
(режим разового запуска — для cron внутри контейнера)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from backend.logging_setup import get_logger
|
||||
from backend.models import AuthEvent, SecuritySetting
|
||||
|
||||
log = get_logger('retention')
|
||||
|
||||
|
||||
def _retention_days(db: Session, default: int = 90) -> int:
|
||||
row = db.query(SecuritySetting).filter(
|
||||
SecuritySetting.key == 'audit_retention_days').first()
|
||||
try:
|
||||
return int(row.value) if row else default
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def purge_logs(db: Session, retention_days: int | None = None) -> dict[str, int]:
|
||||
"""Удалить записи журналов старше retention_days. Возвращает счётчики.
|
||||
|
||||
audit_changes удаляются каскадом по FK (ondelete=CASCADE); для SQLite-тестов
|
||||
(без каскада на уровне БД) удаляем явно.
|
||||
"""
|
||||
if retention_days is None:
|
||||
retention_days = _retention_days(db)
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(days=max(0, retention_days))
|
||||
# SQLite отдаёт naive datetimes (DateTime(timezone=True) не respected) —
|
||||
# сравнение aware-cutoff с naive-колонкой падает TypeError. Убираем tzinfo:
|
||||
cutoff = cutoff.replace(tzinfo=None)
|
||||
|
||||
audit_deleted = 0
|
||||
try:
|
||||
from backend.models import AuditChange, AuditEvent
|
||||
change_ids = (db.query(AuditChange.id)
|
||||
.join(AuditEvent, AuditChange.event_id == AuditEvent.id)
|
||||
.filter(AuditEvent.created_at < cutoff).all())
|
||||
change_ids = [row[0] for row in change_ids]
|
||||
if change_ids:
|
||||
(db.query(AuditChange)
|
||||
.filter(AuditChange.id.in_(change_ids))
|
||||
.delete(synchronize_session=False)) # changes считаются вместе с событием
|
||||
audit_deleted += (db.query(AuditEvent)
|
||||
.filter(AuditEvent.created_at < cutoff)
|
||||
.delete(synchronize_session=False))
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
log.error('purge audit_events не удался: %s', e)
|
||||
raise
|
||||
|
||||
auth_deleted = 0
|
||||
try:
|
||||
auth_deleted += (db.query(AuthEvent)
|
||||
.filter(AuthEvent.created_at < cutoff)
|
||||
.delete(synchronize_session=False))
|
||||
except Exception as e:
|
||||
log.error('purge auth_events не удался: %s', e)
|
||||
raise
|
||||
|
||||
db.commit()
|
||||
if audit_deleted or auth_deleted:
|
||||
log.info('retention purge: %d аудит-записей, %d auth-записей удалено (cutoff=%s, %d дн.)',
|
||||
audit_deleted, auth_deleted, cutoff.date(), retention_days)
|
||||
return {'audit_events': audit_deleted, 'auth_events': auth_deleted}
|
||||
|
||||
|
||||
def _cli() -> None:
|
||||
"""Разовый прогон: python -m backend.purge_logs (для cron)."""
|
||||
from backend.database import SessionLocal
|
||||
with SessionLocal() as db:
|
||||
result = purge_logs(db)
|
||||
print(f'purge: {result}')
|
||||
raise SystemExit(0)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
_cli()
|
||||
@@ -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:
|
||||
@@ -286,3 +304,22 @@ def list_audit(
|
||||
for ch in changes],
|
||||
})
|
||||
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
@@ -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)
|
||||
# Отзываем все сессии после смены пароля (кроме текущей — её пересоздадим)
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
"""B23: тесты исполняемой политики паролей и реальной ретенции журналов.
|
||||
|
||||
SQLite in-memory (те же компиляторы, что в test_e1_rbac).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import bcrypt
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(REPO_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
import sqlalchemy # noqa: E402
|
||||
from sqlalchemy import create_engine # noqa: E402
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID as PG_UUID # noqa: E402
|
||||
from sqlalchemy.ext.compiler import compiles # noqa: E402
|
||||
from sqlalchemy.orm import sessionmaker # noqa: E402
|
||||
from sqlalchemy.pool import StaticPool # noqa: E402
|
||||
|
||||
|
||||
@compiles(PG_UUID, 'sqlite')
|
||||
def _uuid_sqlite(type_, compiler, **kw):
|
||||
return 'CHAR(36)'
|
||||
|
||||
|
||||
@compiles(JSONB, 'sqlite')
|
||||
def _jsonb_sqlite(type_, compiler, **kw):
|
||||
return 'JSON'
|
||||
|
||||
|
||||
@compiles(sqlalchemy.ARRAY, 'sqlite')
|
||||
def _array_sqlite(type_, compiler, **kw):
|
||||
return 'TEXT'
|
||||
|
||||
|
||||
def _hash(pw: str) -> str:
|
||||
return bcrypt.hashpw(pw.encode(), bcrypt.gensalt()).decode()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def policy_db():
|
||||
from backend import models as m
|
||||
from backend.database import Base
|
||||
from backend.models import SecuritySetting
|
||||
|
||||
engine = create_engine(
|
||||
'sqlite:///:memory:',
|
||||
connect_args={'check_same_thread': False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
Base.metadata.create_all(engine)
|
||||
TestingSession = sessionmaker(bind=engine)
|
||||
db = TestingSession()
|
||||
|
||||
user = m.User(username='op1', email='op1@test.by', hashed_password=_hash('oldpass123'),
|
||||
full_name='Оператор', role='operator', is_active=True)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
|
||||
for k, v in (('password_min_length', '8'),
|
||||
('password_require_complexity', 'true'),
|
||||
('password_expiry_days', '90'),
|
||||
('password_history_count', '5'),
|
||||
('audit_retention_days', '90')):
|
||||
db.add(SecuritySetting(key=k, value=v))
|
||||
db.commit()
|
||||
|
||||
yield {'db': db, 'user': user}
|
||||
db.close()
|
||||
|
||||
|
||||
def _validate(env, password, current_hash=None):
|
||||
from backend.password_policy import validate_new_password
|
||||
validate_new_password(env['db'], env['user'], password, current_hash=current_hash)
|
||||
|
||||
|
||||
class TestPasswordValidation:
|
||||
def test_short_rejected(self, policy_db):
|
||||
with pytest.raises(ValueError, match='Минимальная длина'):
|
||||
_validate(policy_db, 'Ab1')
|
||||
|
||||
def test_no_digits_rejected(self, policy_db):
|
||||
with pytest.raises(ValueError, match='буквы и цифры'):
|
||||
_validate(policy_db, 'abcdefgh')
|
||||
|
||||
def test_no_letters_rejected(self, policy_db):
|
||||
with pytest.raises(ValueError, match='буквы и цифры'):
|
||||
_validate(policy_db, '12345678')
|
||||
|
||||
def test_complex_ok(self, policy_db):
|
||||
_validate(policy_db, 'newpass456') # буквы+цифры, длина ок — не бросает
|
||||
|
||||
def test_same_as_current_rejected(self, policy_db):
|
||||
with pytest.raises(ValueError, match='ранее использованным'):
|
||||
_validate(policy_db, 'oldpass123', current_hash=policy_db['user'].hashed_password)
|
||||
|
||||
|
||||
class TestPasswordHistory:
|
||||
def _change(self, env, new_password: str):
|
||||
"""Как в реальном коде: validate → record (со старым хэшем) → подмена."""
|
||||
from backend.password_policy import record_password_change, validate_new_password
|
||||
db, user = env['db'], env['user']
|
||||
validate_new_password(db, user, new_password, current_hash=user.hashed_password)
|
||||
new_hash = _hash(new_password)
|
||||
record_password_change(db, user, new_hash,
|
||||
previous_hash=user.hashed_password)
|
||||
user.hashed_password = new_hash
|
||||
db.commit()
|
||||
|
||||
def test_reuse_recent_rejected(self, policy_db):
|
||||
self._change(policy_db, 'firstpass1')
|
||||
with pytest.raises(ValueError, match='ранее использованным'):
|
||||
_validate(policy_db, 'oldpass123')
|
||||
|
||||
def test_old_password_beyond_history_allowed(self, policy_db):
|
||||
"""Пароль, выпавший из истории, можно вернуть. count=5: история хранит
|
||||
5 последних СТАРЫХ хэшей, текущий (6-й) отдельно. После 6 смен в
|
||||
истории s1..s5, oldpass выпал — разрешён."""
|
||||
for i in range(1, 7):
|
||||
self._change(policy_db, f'secretpw{i}')
|
||||
# oldpass за пределами последних 5 — разрешён
|
||||
_validate(policy_db, 'oldpass123')
|
||||
|
||||
def test_current_password_protected(self, policy_db):
|
||||
"""Текущий пароль тоже нельзя вернуть (сверка с current_hash)."""
|
||||
self._change(policy_db, 'firstpass1')
|
||||
with pytest.raises(ValueError, match='ранее использованным'):
|
||||
_validate(policy_db, 'oldpass123')
|
||||
|
||||
def test_history_trimmed_to_count(self, policy_db):
|
||||
from backend.models import PasswordHistoryEntry
|
||||
db = policy_db['db']
|
||||
for i in range(1, 8):
|
||||
self._change(policy_db, f'secretpw{i}')
|
||||
count = db.query(PasswordHistoryEntry).count()
|
||||
assert count == 5
|
||||
|
||||
def test_same_password_rejected_immediately(self, policy_db):
|
||||
with pytest.raises(ValueError, match='ранее использованным'):
|
||||
_validate(policy_db, 'oldpass123', current_hash=policy_db['user'].hashed_password)
|
||||
|
||||
|
||||
class TestPasswordExpiry:
|
||||
def test_fresh_password_not_expired(self, policy_db):
|
||||
from backend.password_policy import password_expired
|
||||
assert not password_expired(policy_db['db'], policy_db['user'])
|
||||
|
||||
def test_expired_after_90_days(self, policy_db):
|
||||
from backend.password_policy import password_expired
|
||||
db, user = policy_db['db'], policy_db['user']
|
||||
user.password_changed_at = datetime.now(timezone.utc) - timedelta(days=91)
|
||||
db.commit()
|
||||
assert password_expired(db, user)
|
||||
|
||||
def test_not_expired_within_90_days(self, policy_db):
|
||||
from backend.password_policy import password_expired
|
||||
db, user = policy_db['db'], policy_db['user']
|
||||
user.password_changed_at = datetime.now(timezone.utc) - timedelta(days=89)
|
||||
db.commit()
|
||||
assert not password_expired(db, user)
|
||||
|
||||
def test_expiry_disabled_when_zero(self, policy_db):
|
||||
from backend.password_policy import password_expired
|
||||
db, user = policy_db['db'], policy_db['user']
|
||||
from backend.models import SecuritySetting
|
||||
row = db.query(SecuritySetting).filter(SecuritySetting.key == 'password_expiry_days').first()
|
||||
row.value = '0'
|
||||
db.commit()
|
||||
user.password_changed_at = datetime.now(timezone.utc) - timedelta(days=400)
|
||||
db.commit()
|
||||
assert not password_expired(db, user)
|
||||
|
||||
def test_naive_datetime_handled(self, policy_db):
|
||||
from backend.password_policy import password_expired
|
||||
db, user = policy_db['db'], policy_db['user']
|
||||
user.password_changed_at = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(days=91)
|
||||
db.commit()
|
||||
assert password_expired(db, user)
|
||||
|
||||
|
||||
class TestRetentionPurge:
|
||||
@pytest.fixture()
|
||||
def logs_db(self, policy_db):
|
||||
from backend.models import AuditChange, AuditEvent, AuthEvent
|
||||
db = policy_db['db']
|
||||
now = datetime.now(timezone.utc)
|
||||
# 2 старых (200 дн.) + 2 свежих (10 дн.): событие + change + auth-запись на каждое
|
||||
for days in (200, 10):
|
||||
for i in (1, 2):
|
||||
ev = AuditEvent(username='op1', event_type='user_update', created_at=now - timedelta(days=days))
|
||||
db.add(ev)
|
||||
db.flush()
|
||||
db.add(AuditChange(event_id=ev.id, field_name='f', old_value='a', new_value='b'))
|
||||
db.add(AuthEvent(username='op1', event_type='login', created_at=now - timedelta(days=days)))
|
||||
db.commit()
|
||||
yield {'db': db, 'now': now}
|
||||
db.close()
|
||||
|
||||
def test_purge_deletes_old(self, logs_db):
|
||||
from backend.models import AuditChange, AuditEvent, AuthEvent
|
||||
from backend.purge_logs import purge_logs
|
||||
db = logs_db['db']
|
||||
result = purge_logs(db)
|
||||
assert result == {'audit_events': 2, 'auth_events': 2}
|
||||
assert db.query(AuditEvent).count() == 2
|
||||
assert db.query(AuthEvent).count() == 2
|
||||
# field-level изменения старых событий удалены вместе с событиями
|
||||
assert db.query(AuditChange).count() == 2
|
||||
|
||||
def test_purge_respects_setting(self, logs_db):
|
||||
from backend.models import SecuritySetting
|
||||
from backend.purge_logs import purge_logs
|
||||
db = logs_db['db']
|
||||
row = db.query(SecuritySetting).filter(SecuritySetting.key == 'audit_retention_days').first()
|
||||
row.value = '10000' # всё свежее — ничего не удалить
|
||||
db.commit()
|
||||
result = purge_logs(db)
|
||||
assert result == {'audit_events': 0, 'auth_events': 0}
|
||||
|
||||
def test_purge_idempotent(self, logs_db):
|
||||
from backend.purge_logs import purge_logs
|
||||
purge_logs(logs_db['db'])
|
||||
second = purge_logs(logs_db['db'])
|
||||
assert second == {'audit_events': 0, 'auth_events': 0}
|
||||
|
||||
def test_purge_endpoint_requires_manage_security(self, logs_db):
|
||||
"""Без права manage_security — 403."""
|
||||
from backend.main import app
|
||||
from backend.routers import auth as auth_router
|
||||
from fastapi.testclient import TestClient
|
||||
from types import SimpleNamespace
|
||||
|
||||
app.dependency_overrides[auth_router.get_db] = lambda: logs_db['db']
|
||||
app.dependency_overrides[auth_router.get_current_user] = lambda: SimpleNamespace(
|
||||
id='u1', username='op1', role='operator', is_active=True, unit_id=None,
|
||||
must_change_password=False, status='active', failed_login_count=0,
|
||||
locked_until=None, email='op1@test.by', full_name='Оператор')
|
||||
try:
|
||||
client = TestClient(app)
|
||||
r = client.post('/api/v1/admin/users/maintenance/purge-logs')
|
||||
assert r.status_code == 403
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
Reference in New Issue
Block a user