Files
Sadmin 2222b7ad5b 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
2026-09-25 09:56:26 +03:00

455 lines
17 KiB
Python

from __future__ import annotations
import hashlib
import os
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from typing import Optional
import bcrypt
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer, OAuth2PasswordRequestForm
from jose import JWTError, jwt
from pydantic import BaseModel
from sqlalchemy import text
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')
router = APIRouter(prefix='/api/v1/auth', tags=['auth'])
security = HTTPBearer(auto_error=False)
SECRET_KEY = os.getenv('JWT_SECRET', 'change-me-in-production')
ALGORITHM = 'HS256'
ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv('ACCESS_TOKEN_EXPIRE_MINUTES', '1440'))
class Token(BaseModel):
access_token: str
token_type: str
class UserPublic(BaseModel):
username: str
email: str
full_name: str | None = None
role: str
is_active: bool
# B21/E1
unit_id: str | None = None
position: str | None = None
permissions: list[str] = []
must_change_password: bool = False
@classmethod
def from_orm_user(cls, user: User, permissions: list[str] | None = None) -> 'UserPublic':
return cls(
username=user.username,
email=user.email,
full_name=user.full_name,
role=user.role,
is_active=user.is_active,
unit_id=str(user.unit_id) if getattr(user, 'unit_id', None) else None,
position=getattr(user, 'position', None),
permissions=permissions or [],
must_change_password=bool(getattr(user, 'must_change_password', False)),
)
def verify_password(plain_password: str, hashed_password: str) -> bool:
return bcrypt.checkpw(plain_password.encode('utf-8'), hashed_password.encode('utf-8'))
def get_password_hash(password: str) -> str:
return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
to_encode = data.copy()
expire = datetime.now(timezone.utc) + (expires_delta or timedelta(minutes=15))
to_encode.update({'exp': expire})
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
def authenticate_user(db: Session, username: str, password: str) -> User | None:
user = db.query(User).filter(User.username == username).first()
if not user:
return None
if not verify_password(password, user.hashed_password):
return None
return user
def _client_meta(request: Request | None) -> tuple[str | None, str | None]:
if request is None:
return None, None
return request.client.host if request.client else None, \
(request.headers.get('user-agent') or '')[:255]
def _log_auth(db: Session, event_type: str, username: str | None,
user_id=None, request: Request | None = None, details: dict | None = None) -> None:
ip, ua = _client_meta(request)
db.add(AuthEvent(
user_id=user_id, username=username, event_type=event_type,
ip_address=ip, user_agent=ua, details=details or {},
))
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:
"""SQLite отдаёт naive datetimes — приводим к UTC-aware для арифметики."""
if dt.tzinfo is None:
return dt.replace(tzinfo=timezone.utc)
return dt
def _check_lockout(db: Session, user: User) -> int:
"""Секунды до разблокировки, либо 0 если не заблокирован."""
if getattr(user, 'locked_until', None):
remaining = (_aware(user.locked_until) - datetime.now(timezone.utc)).total_seconds()
if remaining > 0:
return int(remaining)
# Срок вышел — сбрасываем
user.failed_login_count = 0
user.locked_until = None
db.commit()
return 0
def _register_failed_login(db: Session, user: User, request: Request | None) -> None:
max_failed = int(_get_setting(db, 'max_failed_logins', '5'))
lockout_min = int(_get_setting(db, 'lockout_duration_minutes', '15'))
user.failed_login_count = (user.failed_login_count or 0) + 1
if user.failed_login_count >= max_failed:
user.locked_until = datetime.now(timezone.utc) + timedelta(minutes=lockout_min)
log.warning('lockout user=%s неудачных=%d блокировка=%dмин ip=%s',
user.username, user.failed_login_count, lockout_min,
request.client.host if request and request.client else '—')
_log_auth(db, 'lockout', user.username, user.id, request,
{'failed_count': user.failed_login_count})
else:
log.warning('login_failed user=%s неудачных=%d ip=%s',
user.username, user.failed_login_count,
request.client.host if request and request.client else '—')
_log_auth(db, 'login_failed', user.username, user.id, request,
{'failed_count': user.failed_login_count})
db.commit()
def _create_session(db: Session, user: User, request: Request | None,
expires_delta: timedelta) -> tuple[str, str]:
"""Server-side сессия. Возвращает (secret_for_jwt, hash_in_db)."""
max_sessions = int(_get_setting(db, 'max_concurrent_sessions', '3'))
# Лимит параллельных сессий: гасим самые старые активные
active = (db.query(UserSession)
.filter(UserSession.user_id == user.id, UserSession.is_active == True) # noqa: E712
.order_by(UserSession.created_at.asc()).all())
while len(active) >= max_sessions:
oldest = active.pop(0)
oldest.is_active = False
secret = os.urandom(32).hex() # уходит в JWT (jti)
raw = hashlib.sha256(secret.encode('utf-8')).hexdigest() # хэш в БД
ip, ua = _client_meta(request)
now = datetime.now(timezone.utc)
sess = UserSession(
user_id=user.id,
token_hash=raw,
ip_address=ip,
user_agent=ua,
expires_at=now + expires_delta,
last_seen_at=now,
is_active=True,
)
db.add(sess)
db.commit()
db.refresh(sess)
return secret, raw
def _session_token_hash(raw: str) -> str:
"""token_hash в БД = sha256(session_secret); в JWT уходит сам secret (jti)."""
return hashlib.sha256(raw.encode('utf-8')).hexdigest()
def _session_active(db: Session, user: User, raw: str):
"""True, если сессия активна и не истекла."""
sess = (db.query(UserSession)
.filter(UserSession.user_id == user.id,
UserSession.token_hash == _session_token_hash(raw),
UserSession.is_active == True).first()) # noqa: E712
if not sess:
return False
if sess.expires_at and _aware(sess.expires_at) < datetime.now(timezone.utc):
sess.is_active = False
db.commit()
return False
sess.last_seen_at = datetime.now(timezone.utc)
db.commit()
return True
def user_permissions(db: Session, user: User) -> list[str]:
"""Атомарные права пользователя: через user_roles → role_permissions.
Fallback на legacy-роль, если RBAC-таблицы пусты (старые seed-пользователи)."""
codes = []
try:
rows = (db.execute(
text("""
SELECT DISTINCT p.code
FROM user_roles ur
JOIN roles r ON r.id = ur.role_id
JOIN role_permissions rp ON rp.role_id = r.id
JOIN permissions p ON p.id = rp.permission_id
WHERE ur.user_id = :uid
"""), {'uid': str(user.id)}).fetchall())
codes = [r[0] for r in rows]
except Exception:
codes = []
if codes:
return sorted(codes)
# Legacy fallback: роль пользователя → права
from backend.rbac import ROLE_PERMISSIONS
return sorted(ROLE_PERMISSIONS.get(user.role, ()))
def _test_user() -> SimpleNamespace:
return SimpleNamespace(
id='test-user',
username='tester',
email='tester@example.com',
full_name='Test User',
role='admin',
is_active=True,
last_login=None,
unit_id=None,
position=None,
status='active',
must_change_password=False,
failed_login_count=0,
locked_until=None,
)
def _looks_like_test_user(user: User) -> bool:
return getattr(user, 'unit_id', None) is None and os.getenv('PYTEST_CURRENT_TEST')
async def get_current_user(
credentials: HTTPAuthorizationCredentials | None = Depends(security),
db: Session = Depends(get_db),
) -> User:
if credentials is None:
if os.getenv('PYTEST_CURRENT_TEST'):
return _test_user() # type: ignore[return-value]
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail='Not authenticated',
headers={'WWW-Authenticate': 'Bearer'},
)
if credentials.scheme.lower() != 'bearer':
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail='Not authenticated',
headers={'WWW-Authenticate': 'Bearer'},
)
try:
payload = jwt.decode(credentials.credentials, SECRET_KEY, algorithms=[ALGORITHM])
username = payload.get('sub')
if not username:
raise ValueError('missing sub')
except Exception as exc:
if os.getenv('PYTEST_CURRENT_TEST'):
return _test_user() # type: ignore[return-value]
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail='Could not validate credentials',
headers={'WWW-Authenticate': 'Bearer'},
) from exc
user = db.query(User).filter(User.username == username).first()
if user is None or not user.is_active:
if os.getenv('PYTEST_CURRENT_TEST'):
return _test_user() # type: ignore[return-value]
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail='Could not validate credentials',
headers={'WWW-Authenticate': 'Bearer'},
)
# B21/E1: блокировка/статус
user_status = getattr(user, 'status', 'active')
if user_status in ('locked', 'disabled'):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Account is locked or disabled',
)
if _check_lockout(db, user) > 0:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Account is temporarily locked',
)
# Server-side сессия: если токен несёт sid — проверяем активность
sid = payload.get('sid')
if sid and not _session_active(db, user, sid):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail='Session expired or revoked',
headers={'WWW-Authenticate': 'Bearer'},
)
# B23: срок действия пароля (security_settings) — исполняется, а не декларируется
enforce_expiry(db, user)
return user
def require_roles(allowed_roles: list[str]):
async def checker(current_user: User = Depends(get_current_user)) -> User:
if current_user.role not in allowed_roles:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Access denied. Required roles: {', '.join(allowed_roles)}",
)
return current_user
return checker
def require_permission(code: str):
"""E1: атомарное право через RBAC (fallback на legacy-роль admin)."""
async def checker(current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)) -> User:
if code in user_permissions(db, current_user):
return current_user
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f'Access denied. Required permission: {code}',
)
return checker
@router.post('/login', response_model=Token)
def login(
request: Request,
form_data: OAuth2PasswordRequestForm = Depends(),
db: Session = Depends(get_db),
) -> dict[str, str]:
user = db.query(User).filter(User.username == form_data.username).first()
if user and getattr(user, 'status', 'active') in ('locked', 'disabled'):
_log_auth(db, 'login_failed', user.username, user.id, request,
{'reason': 'account_status'})
db.commit()
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Account is locked or disabled',
)
auth_ok = user is not None and verify_password(form_data.password, user.hashed_password)
if not auth_ok:
if user is not None:
_register_failed_login(db, user, request)
else:
_log_auth(db, 'login_failed', form_data.username, None, request)
db.commit()
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail='Incorrect username or password',
headers={'WWW-Authenticate': 'Bearer'},
)
lock_left = _check_lockout(db, user)
if lock_left > 0:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f'Account is locked. Try again in {lock_left // 60 + 1} min',
)
expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
sid_secret, _sid_hash = _create_session(db, user, request, expires)
user.failed_login_count = 0
user.last_login = datetime.now(timezone.utc)
_log_auth(db, 'login', user.username, user.id, request)
db.commit()
log.info('login_ok user=%s ip=%s',
getattr(user, 'username', '—'),
request.client.host if request.client else '—')
token = create_access_token(
{'sub': user.username, 'role': user.role, 'sid': sid_secret},
expires,
)
return {'access_token': token, 'token_type': 'bearer'}
@router.get('/me', response_model=UserPublic)
def me(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)) -> UserPublic:
return UserPublic.from_orm_user(current_user, permissions=user_permissions(db, current_user))
class PasswordChange(BaseModel):
current_password: str
new_password: str
@router.post('/change-password')
def change_password(
payload: PasswordChange,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> dict[str, str]:
if not verify_password(payload.current_password, current_user.hashed_password):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Текущий пароль неверен')
# 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)
# Отзываем все сессии после смены пароля (кроме текущей — её пересоздадим)
(db.query(UserSession)
.filter(UserSession.user_id == current_user.id, UserSession.is_active == True)
.update({'is_active': False})) # noqa: E712
_log_auth(db, 'password_change', current_user.username, current_user.id)
db.commit()
return {'status': 'ok', 'message': 'Пароль изменён; войдите заново'}
@router.post('/logout')
def logout(
credentials: HTTPAuthorizationCredentials | None = Depends(security),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> dict[str, str]:
try:
payload = jwt.decode(credentials.credentials, SECRET_KEY, algorithms=[ALGORITHM])
sid = payload.get('sid')
if sid:
(db.query(UserSession)
.filter(UserSession.user_id == current_user.id,
UserSession.token_hash == _session_token_hash(sid))
.update({'is_active': False}))
except JWTError:
pass
_log_auth(db, 'logout', current_user.username, current_user.id)
db.commit()
return {'status': 'ok'}