bmc_hub/app/modules/remote/security.py

93 lines
4.2 KiB
Python
Raw Normal View History

"""Remote credentials are deliberately separate from normal Hub authentication."""
import hashlib
import hmac
import math
import secrets
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
from fastapi import HTTPException, Request, Response
from app.core.config import settings
from app.core.auth_service import AuthService
from app.core.database import atomic_transaction, execute_query, execute_query_single
COOKIE = 'hub_remote_device'
CSRF_COOKIE = 'hub_remote_csrf'
def today(now=None):
return (now or datetime.now(timezone.utc)).astimezone(ZoneInfo('Europe/Copenhagen')).date()
def digest(value):
return hashlib.sha256(value.encode()).hexdigest()
def lookup(value):
return hmac.new(settings.REMOTE_LOOKUP_KEY.encode(), value.encode(), hashlib.sha256).hexdigest()
def new_code():
return f'{secrets.randbelow(10**12):012d}'
def guard(request: Request, response: Response):
response.headers['Cache-Control'] = 'no-store'
if not settings.REMOTE_ENABLED:
raise HTTPException(404, 'Fjernbetjening er slået fra')
if len(settings.REMOTE_LOOKUP_KEY) < 32:
raise HTTPException(503, 'Fjernbetjening mangler sikker serverkonfiguration')
if request.method not in {'GET', 'HEAD', 'OPTIONS'}:
origin = str(request.base_url).rstrip('/')
if request.headers.get('origin') != origin:
raise HTTPException(403, 'Ugyldig oprindelse')
cookie = request.cookies.get(CSRF_COOKIE, '')
token = request.headers.get('x-remote-csrf', '')
if not cookie or not hmac.compare_digest(cookie, token):
raise HTTPException(403, 'Opdatér siden og prøv igen')
def reserve_attempt(ip, user_id=None, device_id=None):
"""Commit even failed attempts, and serialize all buckets across worker processes."""
buckets = sorted({lookup('ip:' + ip), *([lookup('user:' + str(user_id))] if user_id else []),
*([lookup('device:' + str(device_id))] if device_id else [])})
blocked = None
with atomic_transaction():
for bucket in buckets:
execute_query("INSERT INTO remote_attempts(bucket,next_allowed_at) VALUES(%s,'epoch') ON CONFLICT DO NOTHING", (bucket,))
rows = execute_query('SELECT next_allowed_at FROM remote_attempts WHERE bucket=ANY(%s) ORDER BY bucket FOR UPDATE', (buckets,))
now = execute_query_single('SELECT clock_timestamp() AS now')['now']
wait = max([(row['next_allowed_at'] - now).total_seconds() for row in rows] + [0])
if wait > 0:
blocked = max(1, math.ceil(wait))
else:
execute_query("UPDATE remote_attempts SET next_allowed_at=clock_timestamp()+INTERVAL '30 seconds' WHERE bucket=ANY(%s)", (buckets,))
if blocked:
raise HTTPException(429, 'Vent før næste loginforsøg', headers={'Retry-After': str(blocked)})
def remote_device(request, unlocked=True):
token = request.cookies.get(COOKIE)
if not token:
raise HTTPException(401, 'Forbind enheden med din kode')
row = execute_query_single('''SELECT d.*,r.user_id,p.generation,p.pin_hash,u.username,u.full_name,u.is_superadmin
FROM remote_devices d JOIN remote_controls r ON r.id=d.remote_id
JOIN remote_pins p ON p.user_id=r.user_id JOIN users u ON u.user_id=r.user_id
WHERE d.token_hash=%s AND NOT d.revoked AND NOT r.revoked AND d.expires_at>NOW() AND u.is_active=TRUE''', (digest(token),))
if not row:
raise HTTPException(401, 'Forbindelsen er udløbet eller tilbagekaldt')
if unlocked and (row['unlocked_date'] != today() or row['pin_generation'] != row['generation']):
raise HTTPException(423, 'Indtast dagens PIN')
return row
def actor(device):
# Current permissions are fetched for every request; no cached admin JWT is minted.
return {'id': device['user_id'], 'username': device['username'], 'full_name': device['full_name'],
'is_superadmin': device['is_superadmin'], 'permissions': AuthService.get_user_permissions(device['user_id'])}
def case_permission(user):
if not user.get('is_superadmin') and not {'cases.view', 'tickets.view'}.intersection(user.get('permissions') or []):
raise HTTPException(403, 'Du har ikke adgang til sager')