"""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')