"""Agent authentication: per-PC credentials, with the shared key as fallback. Every agent-facing endpoints route authenticates through authenticate_agent() and, when the route names a PC, binds the caller through bind_hostname(). X-Endpoint-Credential The PC's own secret. The server derives the asset from it; a hostname or assetid in the request that names a different asset is refused with ASSET_MISMATCH. X-API-Key The shared fleet key (or a collector-scoped managed token). Accepted only while plugin.endpoints.sharedkeyallowed is on, and never for a PC that holds an active credential. A PC blocked from re-enrolling has no active credential, so an older agent on it keeps working on the shared key. Only the sha256 of a credential is stored. The full secret exists once, in the enroll response. """ import hashlib import hmac import re import secrets from datetime import datetime, timedelta, timezone from flask import g, request from shopdb.api import ( db, Setting, error_response, ErrorCodes, collector_authorized, ) CREDENTIAL_HEADER = 'X-Endpoint-Credential' CREDENTIAL_PREFIX = 'sdbe_' # sdbe_ + 43 chars base64url (32 random bytes, unpadded). _CREDENTIAL_PATTERN = re.compile(r'^sdbe_[A-Za-z0-9_-]{43}$') DISPLAY_PREFIX_LENGTH = 12 SHARED_KEY_SETTING = 'plugin.endpoints.sharedkeyallowed' AUTO_ENROLL_SETTING = 'plugin.endpoints.autoenroll' CODE_CREDENTIAL_INVALID = 'CREDENTIAL_INVALID' CODE_SHARED_KEY_REFUSED = 'SHARED_KEY_REFUSED' CODE_ASSET_MISMATCH = 'ASSET_MISMATCH' AUTH_CREDENTIAL = 'credential' AUTH_SHARED_KEY = 'sharedkey' # lastusedat is a display value, not an audit trail: one write per 5 minutes # per PC keeps the long-poll from turning every claim into an UPDATE. _TOUCH_INTERVAL = timedelta(minutes=5) def _naive_utcnow(): return datetime.now(timezone.utc).replace(tzinfo=None) def generate_credential(): return CREDENTIAL_PREFIX + secrets.token_urlsafe(32) def hash_credential(secret): return hashlib.sha256(secret.encode('utf-8')).hexdigest() def shared_key_allowed(): return Setting.get(SHARED_KEY_SETTING, default=True) is not False def auto_enroll_enabled(): return Setting.get(AUTO_ENROLL_SETTING, default=True) is not False def active_credential_for(assetid): from ..models import EndpointCredential if assetid is None: return None return (EndpointCredential.query .filter(EndpointCredential.assetid == assetid, EndpointCredential.revokedat.is_(None)) .first()) def reenroll_blocked(assetid): """True when an admin blocked this asset from enrolling again.""" from ..models import EndpointCredential if assetid is None: return False return (EndpointCredential.query .filter(EndpointCredential.assetid == assetid, EndpointCredential.reenrollblocked.is_(True)) .first()) is not None def resolve_credential(secret, touch=True): """Asset id for an active credential, else None.""" from ..models import EndpointCredential if not secret or not _CREDENTIAL_PATTERN.match(secret): return None digest = hash_credential(secret) row = EndpointCredential.query.filter_by(credentialhash=digest).first() if row is None or row.revokedat is not None: return None if not hmac.compare_digest(row.credentialhash, digest): return None if touch: now = _naive_utcnow() if row.lastusedat is None or now - row.lastusedat >= _TOUCH_INTERVAL: row.lastusedat = now db.session.commit() return row.assetid def resolve_hostname(hostname): """Asset id of the one computer with this hostname (case-insensitive).""" from plugins.computers.models import Computer hostname = (hostname or '').strip() if not hostname: return None computer = (Computer.query .filter(db.func.lower(Computer.hostname) == hostname.lower()) .first()) return computer.assetid if computer is not None else None def shared_key_refused_for(hostname): """True when the shared key may not report for this hostname.""" if not shared_key_allowed(): return True assetid = resolve_hostname(hostname) return assetid is not None and active_credential_for(assetid) is not None def credential_invalid_response(): return error_response( CODE_CREDENTIAL_INVALID, 'Endpoint credential is unknown or revoked; enroll again', http_code=401) def shared_key_refused_response(): return error_response( CODE_SHARED_KEY_REFUSED, 'The shared key is not accepted for this endpoint; use its credential', http_code=401) def asset_mismatch_response(): return error_response( CODE_ASSET_MISMATCH, 'The request names a different endpoint than the credential', http_code=403) def authenticate_agent(): """Authorize an agent request. Returns None when authorized, else a response. Sets g.agentauth ('credential' or 'sharedkey') and g.agentassetid (the credential's asset, or None until bind_hostname resolves one). """ g.agentauth = None g.agentassetid = None secret = request.headers.get(CREDENTIAL_HEADER) if secret: assetid = resolve_credential(secret.strip()) if assetid is None: return credential_invalid_response() g.agentauth = AUTH_CREDENTIAL g.agentassetid = assetid return None error = collector_authorized('endpoints') if error is not None: return error if not shared_key_allowed(): return shared_key_refused_response() g.agentauth = AUTH_SHARED_KEY return None def is_credential_caller(): return getattr(g, 'agentauth', None) == AUTH_CREDENTIAL def bind_hostname(hostname): """The asset this request acts for. Returns (assetid, error_response). Credential: the credential's asset; a hostname naming another asset is an ASSET_MISMATCH, and a missing hostname is fine. Shared key: the hostname's asset (None when absent or unknown, for the route to answer); an enrolled PC is refused. """ hostname = (hostname or '').strip() if is_credential_caller(): if hostname and resolve_hostname(hostname) != g.agentassetid: return None, asset_mismatch_response() return g.agentassetid, None if not hostname: return None, None assetid = resolve_hostname(hostname) if assetid is not None and active_credential_for(assetid) is not None: return None, shared_key_refused_response() g.agentassetid = assetid return assetid, None