"""Private recovery endpoint. Accepts only fresh, issuer-signed platform MFA.""" import base64 import hashlib import hmac import json import time from factor_recovery import ProviderStore, RecoveryError, recover def authorized_actor(claims, now=None): now = time.time() if now is None else now assurance = claims.get('assurance', {}) roles = claims.get('roles') if (claims.get('principal_type') != 'human' or not isinstance(roles, list) or 'platform-operator' not in roles or not isinstance(assurance, dict) or assurance.get('level') != 'aal2' or assurance.get('mfa') is not True or not isinstance(assurance.get('at'), (int, float)) or not 0 <= now - assurance['at'] <= 300): raise RecoveryError('fresh_platform_mfa_required') subject = claims.get('sub') if not isinstance(subject, str) or not subject or len(subject) > 150: raise RecoveryError('invalid_actor') return subject class RecoveryService: def __init__(self, store, verify, signing_key, now=time.time): self.store, self.verify, self.key, self.now = store, verify, signing_key, now def ticket(self, request): data = base64.urlsafe_b64encode(json.dumps(dict(request, expires=self.now()+900), sort_keys=True, separators=(',', ':')).encode()).decode() mac = hmac.new(self.key, b'factor-recovery-v1.'+data.encode(), hashlib.sha256).hexdigest() return data+'.'+mac def unticket(self, value, actor): try: data, mac = value.split('.') expected = hmac.new(self.key, b'factor-recovery-v1.'+data.encode(), hashlib.sha256).hexdigest() if not hmac.compare_digest(mac, expected): raise ValueError() request = json.loads(base64.urlsafe_b64decode(data)) if request.pop('expires') < self.now() or request['actor'] != actor: raise ValueError() return request except (ValueError, KeyError, TypeError): raise RecoveryError('preview_expired_or_changed') from None def operation(self, token, body): actor = authorized_actor(self.verify(token), self.now()) if body.get('action') == 'preview': user, reference = body.get('user', ''), body.get('reference', '') # Validate the target and support reference before any directory query. import re if any(not isinstance(v, str) or not re.fullmatch(r'[A-Za-z0-9_.@:/-]{1,150}', v) for v in (user, reference)): raise RecoveryError('invalid_request') factors = [] for serial in self.store.serials(user): request = dict(user=user, reference=reference, serial=serial, actor=actor, realm=self.store.realm) preview = recover(self.store, request) if preview['active']: preview['confirmation'] = self.ticket(dict(request, expected_version=preview['version'])) factors.append(preview) return dict(success=True, status='preview', user=user, reference=reference, factors=factors) if body.get('action') == 'apply': if body.get('identity_verified') is not True: raise RecoveryError('identity_verification_required') request = self.unticket(body.get('confirmation', ''), actor) return dict(recover(self.store, dict(request, apply=True, identity_verified=True)), success=True) raise RecoveryError('invalid_request') def __call__(self, environ, start_response): if environ.get('PATH_INFO') == '/healthz' and environ.get('REQUEST_METHOD') == 'GET': start_response('200 OK', [('Content-Type', 'application/json')]) return [b'{"ready":true}'] status = '200 OK' try: if environ.get('PATH_INFO') != '/recover' or environ.get('REQUEST_METHOD') != 'POST': raise RecoveryError('invalid_request') length = int(environ.get('CONTENT_LENGTH') or 0) if not 0 < length <= 16384: raise RecoveryError('invalid_request') auth = environ.get('HTTP_AUTHORIZATION', '') if not auth.startswith('Bearer ') or len(auth) > 16384: raise RecoveryError('fresh_platform_mfa_required') body = json.loads(environ['wsgi.input'].read(length)) if not isinstance(body, dict): raise RecoveryError('invalid_request') result = self.operation(auth[7:], body) except RecoveryError as exc: status = '403 Forbidden' if str(exc) == 'fresh_platform_mfa_required' else '409 Conflict' result = dict(success=False, failure=str(exc)) except Exception: status, result = '503 Service Unavailable', dict(success=False, failure='recovery_unavailable') start_response(status, [('Content-Type', 'application/json'), ('Cache-Control', 'no-store')]) return [json.dumps(result).encode()] def verify_token(token, public_key, issuer, audience): import jwt try: return jwt.decode(token, public_key, algorithms=['RS256'], issuer=issuer, audience=audience, options={'require': ['iss', 'aud', 'exp', 'iat', 'sub']}) except Exception: raise RecoveryError('fresh_platform_mfa_required') from None def main(): import logging, os import jwt from privacyidea.app import create_app from wsgiref.simple_server import make_server, WSGIRequestHandler logging.disable(logging.CRITICAL) app = create_app(config_name='production', silent=True) keys = jwt.PyJWKClient(os.environ['RECOVERY_JWKS_URL'], timeout=5) def verify(token): try: key = keys.get_signing_key_from_jwt(token) return verify_token(token, key.key, os.environ['RECOVERY_ISSUER'], os.environ['RECOVERY_AUDIENCE']) except Exception: raise RecoveryError('fresh_platform_mfa_required') from None secret = app.config['SECRET_KEY'] service = RecoveryService(ProviderStore(app), verify, secret.encode() if isinstance(secret,str) else secret) def application(environ, start_response): with app.app_context(): return service(environ, start_response) class Quiet(WSGIRequestHandler): def log_message(self, *args): pass with make_server('0.0.0.0', 8091, application, handler_class=Quiet) as server: server.serve_forever() if __name__ == '__main__': main()