379 lines
13 KiB
Python
379 lines
13 KiB
Python
"""FastAPI dependencies for cabinet module."""
|
|
|
|
from datetime import UTC, datetime
|
|
|
|
import structlog
|
|
from fastapi import Depends, HTTPException, Request, status
|
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.config import settings
|
|
from app.database.crud.user import get_user_by_id
|
|
from app.database.database import AsyncSessionLocal
|
|
from app.database.models import User
|
|
from app.services.blacklist_service import blacklist_service
|
|
from app.services.maintenance_service import maintenance_service
|
|
|
|
from .auth.jwt_handler import get_token_payload
|
|
from .auth.telegram_auth import validate_telegram_init_data
|
|
from .ip_utils import get_client_ip
|
|
|
|
|
|
logger = structlog.get_logger(__name__)
|
|
|
|
security = HTTPBearer(auto_error=False)
|
|
|
|
|
|
async def get_cabinet_db() -> AsyncSession:
|
|
"""Get database session for cabinet operations."""
|
|
async with AsyncSessionLocal() as session:
|
|
try:
|
|
yield session
|
|
finally:
|
|
await session.close()
|
|
|
|
|
|
async def get_current_cabinet_user(
|
|
request: Request,
|
|
credentials: HTTPAuthorizationCredentials | None = Depends(security),
|
|
db: AsyncSession = Depends(get_cabinet_db),
|
|
) -> User:
|
|
"""
|
|
Get current authenticated cabinet user from JWT token.
|
|
|
|
Args:
|
|
request: FastAPI request object (for reading X-Telegram-Init-Data header)
|
|
credentials: HTTP Bearer credentials
|
|
db: Database session
|
|
|
|
Returns:
|
|
Authenticated User object
|
|
|
|
Raises:
|
|
HTTPException: If token is invalid, expired, or user not found
|
|
"""
|
|
# Check maintenance mode first (except for admins - checked later)
|
|
if maintenance_service.is_maintenance_active():
|
|
# We need to check token first to see if user is admin
|
|
pass # Will check after getting user
|
|
|
|
if not credentials:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail='Authentication required',
|
|
headers={'WWW-Authenticate': 'Bearer'},
|
|
)
|
|
|
|
token = credentials.credentials
|
|
payload = get_token_payload(token, expected_type='access')
|
|
|
|
if not payload:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail='Invalid or expired token',
|
|
headers={'WWW-Authenticate': 'Bearer'},
|
|
)
|
|
|
|
try:
|
|
user_id = int(payload.get('sub'))
|
|
except (TypeError, ValueError):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail='Invalid token payload',
|
|
headers={'WWW-Authenticate': 'Bearer'},
|
|
)
|
|
|
|
user = await get_user_by_id(db, user_id)
|
|
|
|
if not user:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail='User not found',
|
|
)
|
|
|
|
if user.status != 'active':
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail='User account is not active',
|
|
)
|
|
|
|
# Defense in depth: cross-validate Telegram identity.
|
|
# The frontend sends X-Telegram-Init-Data on every request.
|
|
# If the header is present and cryptographically valid, verify that
|
|
# the Telegram user ID matches the JWT user's telegram_id.
|
|
# This prevents cross-account token reuse when Telegram WebView
|
|
# shares localStorage across accounts on the same device.
|
|
init_data_raw = request.headers.get('X-Telegram-Init-Data')
|
|
if init_data_raw and user.telegram_id is not None:
|
|
# Use generous max_age: Telegram Desktop caches initData
|
|
tg_user = validate_telegram_init_data(init_data_raw, max_age_seconds=86400 * 30)
|
|
if tg_user is None:
|
|
logger.warning(
|
|
'Telegram initData validation failed but header was present',
|
|
jwt_user_id=user.id,
|
|
)
|
|
elif tg_user.get('id') != user.telegram_id:
|
|
logger.warning(
|
|
'Telegram identity mismatch: JWT belongs to different user than current Telegram account',
|
|
jwt_user_id=user.id,
|
|
jwt_telegram_id=user.telegram_id,
|
|
init_data_telegram_id=tg_user.get('id'),
|
|
)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail='Session belongs to a different Telegram account. Please restart the app.',
|
|
headers={'WWW-Authenticate': 'Bearer'},
|
|
)
|
|
|
|
# Check blacklist
|
|
if user.telegram_id is not None:
|
|
is_blacklisted, reason = await blacklist_service.is_user_blacklisted(user.telegram_id, user.username)
|
|
if is_blacklisted:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail={
|
|
'code': 'blacklisted',
|
|
'message': reason or 'Доступ запрещен',
|
|
},
|
|
)
|
|
|
|
# Check maintenance mode (allow admins to pass)
|
|
if maintenance_service.is_maintenance_active():
|
|
# Проверяем админа по telegram_id ИЛИ email
|
|
is_admin = settings.is_admin(telegram_id=user.telegram_id, email=user.email if user.email_verified else None)
|
|
if not is_admin:
|
|
status_info = maintenance_service.get_status_info()
|
|
raise HTTPException(
|
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
detail={
|
|
'code': 'maintenance',
|
|
'message': maintenance_service.get_maintenance_message() or 'Service is under maintenance',
|
|
'reason': status_info.get('reason'),
|
|
},
|
|
)
|
|
|
|
# Check required channel subscription - Telegram users only
|
|
if settings.CHANNEL_IS_REQUIRED_SUB:
|
|
# Skip for email-only users (no telegram_id)
|
|
if user.telegram_id is not None:
|
|
# Skip admin check
|
|
is_admin = settings.is_admin(
|
|
telegram_id=user.telegram_id, email=user.email if user.email_verified else None
|
|
)
|
|
if not is_admin:
|
|
from app.services.channel_subscription_service import channel_subscription_service
|
|
|
|
channels_with_status = await channel_subscription_service.get_channels_with_status(user.telegram_id)
|
|
is_subscribed = (
|
|
all(ch['is_subscribed'] for ch in channels_with_status) if channels_with_status else True
|
|
)
|
|
|
|
if not is_subscribed:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail={
|
|
'code': 'channel_subscription_required',
|
|
'message': 'Please subscribe to the required channels to continue',
|
|
'channels': channels_with_status,
|
|
},
|
|
)
|
|
|
|
# Throttled update of cabinet_last_login (at most every 5 minutes)
|
|
now = datetime.now(UTC)
|
|
if not user.cabinet_last_login or (now - user.cabinet_last_login).total_seconds() > 300:
|
|
try:
|
|
user.cabinet_last_login = now
|
|
await db.commit()
|
|
except Exception:
|
|
pass
|
|
|
|
return user
|
|
|
|
|
|
async def get_optional_cabinet_user(
|
|
request: Request,
|
|
credentials: HTTPAuthorizationCredentials | None = Depends(security),
|
|
db: AsyncSession = Depends(get_cabinet_db),
|
|
) -> User | None:
|
|
"""
|
|
Optionally get current authenticated cabinet user.
|
|
|
|
Returns None if no valid token is provided instead of raising an exception.
|
|
"""
|
|
if not credentials:
|
|
return None
|
|
|
|
token = credentials.credentials
|
|
payload = get_token_payload(token, expected_type='access')
|
|
|
|
if not payload:
|
|
return None
|
|
|
|
try:
|
|
user_id = int(payload.get('sub'))
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
user = await get_user_by_id(db, user_id)
|
|
|
|
if not user or user.status != 'active':
|
|
return None
|
|
|
|
# Cross-validate Telegram identity (same as get_current_cabinet_user)
|
|
init_data_raw = request.headers.get('X-Telegram-Init-Data')
|
|
if init_data_raw and user.telegram_id is not None:
|
|
tg_user = validate_telegram_init_data(init_data_raw, max_age_seconds=86400 * 30)
|
|
if tg_user and tg_user.get('id') != user.telegram_id:
|
|
logger.warning(
|
|
'Telegram identity mismatch in optional auth',
|
|
jwt_user_id=user.id,
|
|
jwt_telegram_id=user.telegram_id,
|
|
init_data_telegram_id=tg_user.get('id'),
|
|
)
|
|
return None
|
|
|
|
return user
|
|
|
|
|
|
async def get_current_admin_user(
|
|
request: Request,
|
|
user: User = Depends(get_current_cabinet_user),
|
|
db: AsyncSession = Depends(get_cabinet_db),
|
|
) -> User:
|
|
"""
|
|
Get current authenticated admin user.
|
|
|
|
Checks if the user is admin by legacy config (ADMIN_IDS / ADMIN_EMAILS)
|
|
**or** by RBAC role assignment (any role with level > 0).
|
|
|
|
Args:
|
|
request: FastAPI request object
|
|
user: Authenticated User object
|
|
db: Database session
|
|
|
|
Returns:
|
|
Authenticated admin User object
|
|
|
|
Raises:
|
|
HTTPException: If user is not an admin by either mechanism
|
|
"""
|
|
# Legacy check: config-based admin list
|
|
is_legacy_admin = settings.is_admin(
|
|
telegram_id=user.telegram_id,
|
|
email=user.email if user.email_verified else None,
|
|
)
|
|
if is_legacy_admin:
|
|
return user
|
|
|
|
# RBAC check: user has any active role with level > 0
|
|
from app.database.crud.rbac import UserRoleCRUD
|
|
|
|
_permissions, _role_names, max_level = await UserRoleCRUD.get_user_permissions(db, user.id)
|
|
if max_level > 0:
|
|
return user
|
|
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail='Admin access required',
|
|
)
|
|
|
|
|
|
def require_permission(*permissions: str):
|
|
"""
|
|
FastAPI dependency factory for RBAC permission checks.
|
|
|
|
Usage::
|
|
|
|
@router.get("/users", dependencies=[Depends(require_permission("users:read"))])
|
|
async def list_users(...): ...
|
|
|
|
# Or inject the user:
|
|
@router.get("/users")
|
|
async def list_users(user: User = Depends(require_permission("users:read"))): ...
|
|
"""
|
|
if not permissions:
|
|
raise ValueError('require_permission() requires at least one permission argument')
|
|
|
|
async def dependency(
|
|
request: Request,
|
|
user: User = Depends(get_current_cabinet_user),
|
|
db: AsyncSession = Depends(get_cabinet_db),
|
|
) -> User:
|
|
from app.services.permission_service import PermissionService
|
|
|
|
try:
|
|
client_ip = get_client_ip(request)
|
|
except HTTPException:
|
|
logger.warning('Unable to determine client IP in require_permission')
|
|
client_ip = 'unknown'
|
|
user_agent = request.headers.get('user-agent', '')
|
|
|
|
# Extract resource_type from the first permission (section before ':')
|
|
resource_type = None
|
|
if permissions:
|
|
first_perm = permissions[0]
|
|
if ':' in first_perm:
|
|
resource_type = first_perm.split(':', maxsplit=1)[0]
|
|
|
|
for perm in permissions:
|
|
allowed, reason = await PermissionService.check_permission(
|
|
db,
|
|
user,
|
|
perm,
|
|
ip_address=client_ip,
|
|
)
|
|
if not allowed:
|
|
await PermissionService.log_action(
|
|
db,
|
|
user_id=user.id,
|
|
action=perm,
|
|
resource_type=resource_type,
|
|
status='denied',
|
|
ip_address=client_ip,
|
|
user_agent=user_agent,
|
|
request_method=request.method,
|
|
request_path=str(request.url.path),
|
|
details={'reason': reason},
|
|
)
|
|
await db.commit()
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail=f'Permission denied: {reason}',
|
|
)
|
|
|
|
# Capture request details
|
|
details: dict = {
|
|
'method': request.method,
|
|
'path': str(request.url.path),
|
|
}
|
|
query_params = dict(request.query_params)
|
|
if query_params:
|
|
details['query_params'] = query_params
|
|
if request.method in ('POST', 'PUT', 'PATCH', 'DELETE'):
|
|
try:
|
|
body = await request.body()
|
|
if body:
|
|
import json
|
|
|
|
details['request_body'] = json.loads(body)
|
|
except Exception:
|
|
pass
|
|
|
|
# Log successful access with all requested permissions
|
|
await PermissionService.log_action(
|
|
db,
|
|
user_id=user.id,
|
|
action=','.join(permissions),
|
|
resource_type=resource_type,
|
|
status='success',
|
|
ip_address=client_ip,
|
|
user_agent=user_agent,
|
|
request_method=request.method,
|
|
request_path=str(request.url.path),
|
|
details=details,
|
|
)
|
|
await db.commit()
|
|
return user
|
|
|
|
return dependency
|