2026-09-20 12:27:47 +03:00
|
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
|
|
|
|
|
|
import jwt
|
|
|
|
|
from argon2 import PasswordHasher
|
2026-09-26 13:25:33 +03:00
|
|
|
from argon2.exceptions import VerificationError
|
2026-09-20 12:27:47 +03:00
|
|
|
from fastapi import Depends, HTTPException
|
|
|
|
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
|
|
|
|
from sqlalchemy import select
|
|
|
|
|
from sqlalchemy.orm import Session
|
|
|
|
|
|
|
|
|
|
from app.config import settings
|
|
|
|
|
from app.db import get_db
|
|
|
|
|
from app.models import Role, User
|
|
|
|
|
|
|
|
|
|
_ph = PasswordHasher()
|
|
|
|
|
_bearer = HTTPBearer(auto_error=False)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def hash_password(password: str) -> str:
|
|
|
|
|
return _ph.hash(password)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def verify_password(password: str, hashed: str) -> bool:
|
|
|
|
|
try:
|
|
|
|
|
return _ph.verify(hashed, password)
|
2026-09-26 13:25:33 +03:00
|
|
|
except VerificationError: # несовпадение пароля или повреждённый хэш — не 500, а 401/403
|
2026-09-20 12:27:47 +03:00
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
2026-09-26 21:33:50 +03:00
|
|
|
_EPOCH = datetime(1970, 1, 1, tzinfo=timezone.utc)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _password_version(user: User) -> int:
|
|
|
|
|
"""Версия пароля в токене: смена пароля меняет её, и выданные ранее токены перестают действовать (без зависимости от точности часов).
|
|
|
|
|
Целочисленно (изменение 024, находка №7): без float, хотя результат и был детерминирован."""
|
|
|
|
|
changed = user.password_changed_at
|
|
|
|
|
return (changed - _EPOCH) // timedelta(microseconds=1) if changed else 0
|
|
|
|
|
|
|
|
|
|
|
2026-09-20 12:27:47 +03:00
|
|
|
def create_token(user: User) -> str:
|
2026-09-26 21:33:50 +03:00
|
|
|
now = datetime.now(timezone.utc)
|
|
|
|
|
exp = now + timedelta(minutes=settings.jwt_ttl_minutes)
|
|
|
|
|
# iat — информационное поле (не проверяется при разборе токена); отзыв выданных токенов работает по pv (версия пароля)
|
|
|
|
|
return jwt.encode({"sub": user.username, "iat": now, "exp": exp, "pv": _password_version(user)}, settings.jwt_secret, algorithm="HS256")
|
2026-09-20 12:27:47 +03:00
|
|
|
|
|
|
|
|
|
2026-09-27 16:57:14 +03:00
|
|
|
def create_mfa_token(user: User) -> str:
|
|
|
|
|
"""Промежуточный токен второго шага входа (изменение 037): typ="mfa", короткий срок жизни, не работает как токен доступа."""
|
|
|
|
|
now = datetime.now(timezone.utc)
|
|
|
|
|
exp = now + timedelta(minutes=5)
|
|
|
|
|
return jwt.encode({"sub": user.username, "iat": now, "exp": exp, "pv": _password_version(user), "typ": "mfa"}, settings.jwt_secret, algorithm="HS256")
|
|
|
|
|
|
|
|
|
|
|
2026-09-20 12:27:47 +03:00
|
|
|
def current_user(
|
|
|
|
|
cred: HTTPAuthorizationCredentials | None = Depends(_bearer), db: Session = Depends(get_db)
|
|
|
|
|
) -> User:
|
|
|
|
|
if cred is None:
|
|
|
|
|
raise HTTPException(401, "Требуется авторизация")
|
|
|
|
|
try:
|
2026-09-26 21:33:50 +03:00
|
|
|
payload = jwt.decode(cred.credentials, settings.jwt_secret, algorithms=["HS256"])
|
|
|
|
|
username = payload["sub"]
|
|
|
|
|
except (jwt.PyJWTError, KeyError):
|
2026-09-20 12:27:47 +03:00
|
|
|
raise HTTPException(401, "Недействительный токен")
|
2026-09-27 16:57:14 +03:00
|
|
|
if payload.get("typ"): # изменение 037: mfa_token (typ="mfa") не работает как токен доступа; токены доступа typ не имеют
|
|
|
|
|
raise HTTPException(401, "Недействительный токен")
|
2026-09-20 12:27:47 +03:00
|
|
|
user = db.scalar(select(User).where(User.username == username, User.is_active))
|
|
|
|
|
if user is None:
|
|
|
|
|
raise HTTPException(401, "Пользователь не найден")
|
2026-09-26 21:33:50 +03:00
|
|
|
if payload.get("pv", 0) != _password_version(user):
|
|
|
|
|
raise HTTPException(401, "Пароль изменён, войдите заново") # токен выдан до смены пароля
|
2026-09-20 12:27:47 +03:00
|
|
|
return user
|
|
|
|
|
|
|
|
|
|
|
2026-09-27 16:57:14 +03:00
|
|
|
def decode_mfa_token(token: str, db: Session) -> User:
|
|
|
|
|
"""Разбор и проверка mfa_token второго шага входа (изменение 037): typ == "mfa", пользователь активен,
|
|
|
|
|
версия пароля совпадает (иначе токен выдан до смены пароля — второй шаг не должен переживать смену пароля)."""
|
|
|
|
|
try:
|
|
|
|
|
payload = jwt.decode(token, settings.jwt_secret, algorithms=["HS256"])
|
|
|
|
|
username = payload["sub"]
|
|
|
|
|
except (jwt.PyJWTError, KeyError):
|
|
|
|
|
raise HTTPException(401, "Недействительный или истёкший токен, войдите заново")
|
|
|
|
|
if payload.get("typ") != "mfa":
|
|
|
|
|
raise HTTPException(401, "Недействительный токен")
|
|
|
|
|
user = db.scalar(select(User).where(User.username == username, User.is_active))
|
|
|
|
|
if user is None or payload.get("pv", 0) != _password_version(user):
|
|
|
|
|
raise HTTPException(401, "Недействительный или истёкший токен, войдите заново")
|
|
|
|
|
return user
|
|
|
|
|
|
|
|
|
|
|
2026-09-20 12:27:47 +03:00
|
|
|
def admin_user(user: User = Depends(current_user)) -> User:
|
2026-09-27 11:26:57 +03:00
|
|
|
# изменение 032: admin_user проверяет роль != viewer (пропускает admin И superadmin)
|
|
|
|
|
if user.role == Role.viewer:
|
|
|
|
|
raise HTTPException(403, "Недостаточно прав")
|
|
|
|
|
return user
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def superadmin_user(user: User = Depends(current_user)) -> User:
|
|
|
|
|
# изменение 032: строгая проверка на superadmin
|
|
|
|
|
if user.role != Role.superadmin:
|
2026-09-20 12:27:47 +03:00
|
|
|
raise HTTPException(403, "Недостаточно прав")
|
|
|
|
|
return user
|