2222b7ad5b
- 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
455 lines
17 KiB
Python
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'} |