Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a092fe-13b1-7f12-ac74-7d258af4d79c
136 lines
6.4 KiB
Python
136 lines
6.4 KiB
Python
"""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()
|