#!/usr/bin/python3
# -*- coding: utf-8 -*-
#
# Univention Management Console
#  UMC web server
#
# Like what you see? Join us!
# https://www.univention.com/about-us/careers/vacancies/
#
# Copyright 2011-2023 Univention GmbH
#
# https://www.univention.de/
#
# All rights reserved.
#
# The source code of this program is made available
# under the terms of the GNU Affero General Public License version 3
# (GNU AGPL V3) as published by the Free Software Foundation.
#
# Binary versions of this program provided by Univention to you as
# well as other copyrighted, protected or trademarked materials like
# Logos, graphics, fonts, specific documentations and configurations,
# cryptographic keys etc. are subject to a license agreement between
# you and Univention and not subject to the GNU AGPL V3.
#
# In the case you use this program under the terms of the GNU AGPL V3,
# the program is provided in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public
# License with the Debian GNU/Linux or Univention distribution in file
# /usr/share/common-licenses/AGPL-3; if not, see
# <https://www.gnu.org/licenses/>.

from __future__ import division

import base64
import binascii
import datetime
import functools
import hashlib
import json
import os
import re
import resource
import sys
import tempfile
import threading
import time
import traceback
import uuid
import zlib
from argparse import ArgumentParser
from ipaddress import ip_address

import cherrypy
import notifier
import six
from cherrypy import HTTPError, HTTPRedirect, NotFound
from cherrypy.lib.httputil import valid_status
from defusedxml.common import DefusedXmlException
from saml2 import BINDING_HTTP_ARTIFACT, BINDING_HTTP_POST, BINDING_HTTP_REDIRECT
from saml2.client import Saml2Client
from saml2.ident import code as encode_name_id, decode as decode_name_id
from saml2.metadata import create_metadata_string
from saml2.response import StatusError, UnsolicitedResponse, VerificationError
from saml2.s_utils import UnknownPrincipal, UnsupportedBinding, rndstr
from saml2.sigver import MissingKey, SignatureError
from saml2.validate import ResponseLifetimeExceed
from sdnotify import SystemdNotifier
from six.moves import queue as Queue
from six.moves.http_client import (
    BAD_REQUEST, LENGTH_REQUIRED, NOT_FOUND, REQUEST_ENTITY_TOO_LARGE, SERVICE_UNAVAILABLE, UNAUTHORIZED,
)
from six.moves.urllib_parse import urlparse, urlunsplit

import univention.debug as ud
from univention.lib.i18n import NullTranslation
from univention.management.console.config import get_int, ucr
from univention.management.console.log import CORE, log_init, log_reopen
from univention.management.console.protocol import TEMPUPLOADDIR, Client, NoSocketError, Request, Response


try:
    from html import escape, unescape
except ImportError:  # Python 2
    import HTMLParser
    html_parser = HTMLParser.HTMLParser()
    unescape = html_parser.unescape
    from cgi import escape

try:
    from time import monotonic
except ImportError:
    from monotonic import monotonic

# the SameSite cookie attribute is only available from Python 3.8
from six.moves.http_cookies import Morsel


Morsel._reserved['samesite'] = 'SameSite'

_ = NullTranslation('univention-management-console-frontend').translate

_session_timeout = get_int('umc/http/session/timeout', 300)

PORT = None
REQUEST_ENTITY_TOO_LARGE, LENGTH_REQUIRED, NOT_FOUND, BAD_REQUEST, UNAUTHORIZED, SERVICE_UNAVAILABLE = int(REQUEST_ENTITY_TOO_LARGE), int(LENGTH_REQUIRED), int(NOT_FOUND), int(BAD_REQUEST), int(UNAUTHORIZED), int(SERVICE_UNAVAILABLE)


def sessionidhash():
    session = u'%s%s%s%s' % (cherrypy.request.headers.get('Authorization', ''), cherrypy.request.headers.get('Accept-Language', ''), get_ip_address(), sessionidhash.salt)
    return hashlib.sha256(session.encode('UTF-8')).hexdigest()[:36]
    # TODO: the following is more secure (real random) but also much slower
    return binascii.hexlify(hashlib.pbkdf2_hmac('sha256', session, sessionidhash.salt, 100000))[:36]


sessionidhash.salt = rndstr()


def log_exceptions(func):
    @functools.wraps(func)
    def _decorated(*args, **kwargs):
        try:
            return func(*args, **kwargs)
        except (HTTPError, HTTPRedirect, NotFound, KeyboardInterrupt, SystemExit):
            raise  # ignore system and common cherrypy exceptions
        except Exception:
            CORE.error('Traceback in %s(%r, %r):\n%s' % (func.__name__, args, kwargs, traceback.format_exc()))
            raise
    return _decorated


def _proxy_uri():
    scheme, _, base = cherrypy.request.base.partition('://')
    if cherrypy.request.headers.get('X-UMC-HTTPS') == 'on':
        scheme = 'https'

    cherrypy.request.scheme = scheme
    cherrypy.request.base = '%s://%s/univention' % (scheme, base)
    cherrypy.request.uri = ('%s%s?%s' % (cherrypy.request.base, cherrypy.request.path_info, cherrypy.request.query_string)).rstrip('?')
    foo = cherrypy.request.params
    try:
        cherrypy.request.params = {}
        cherrypy.request.process_query_string()
        cherrypy.request.query = cherrypy.request.params
    finally:
        cherrypy.request.params = foo


cherrypy.tools.fix_uri = cherrypy.Tool('before_request_body', _proxy_uri, priority=30)


class SessionClient(object):

    __slots__ = ('authenticated', 'client', '_requestid2response_queue', '_lock', 'ip')

    def __init__(self, ip=None):
        CORE.info('SessionClient(0x%x): creating new session' % (id(self),))
        self.authenticated = False
        self.client = Client(servername=None, port=None, unix='/run/univention-management-console/server.socket', ssl=False)
        self.client.signal_connect('authenticated', self._authenticated)
        self.client.signal_connect('response', self._response)
        self.client.signal_connect('closed', self._closed)
        try:
            self.client.connect()
            CORE.info('SessionClient(0x%x): connected to UMC server' % (id(self),))
        except NoSocketError:
            CORE.warn('SessionClient(0x%x): connection to UMC server failed' % (id(self),))
            raise NoSocketError('Connection failed')
        self._requestid2response_queue = {}
        self._lock = threading.Lock()
        self.ip = ip

    def _authenticated(self, success, response):
        """Callback function for 'authenticated' from UMCP-Server."""
        CORE.process('SessionClient(0x%x): _authenticated: success=%s  status=%s  message=%s result=%r' % (id(self), success, response.status, response.message, response.result))
        self.authenticated = success
        self._response(response)

    def _response(self, response):
        """Queue response from UMC server."""
        self._lock.acquire()
        try:
            try:
                # get and remove queue for response
                queue = self._requestid2response_queue.pop(response.id)[0]
            except KeyError:
                CORE.process('SessionClient(0x%x): no request(%s) found: status=%s' % (id(self), response.id, response.status))
            else:
                CORE.info('SessionClient(0x%x): got response(%s): status=%s queue=0x%x' % (id(self), response.id, response.status, id(queue)))
                queue.put(response)
        finally:
            self._lock.release()

    def send_request(self, request, response_queue):
        """Send request to UMC server."""
        CORE.info('SessionClient(0x%x): sending request(%s)' % (id(self), request.id))
        self._lock.acquire()
        try:
            self._requestid2response_queue[request.id] = (response_queue, request)
        finally:
            self._lock.release()
        try:
            self.client.request(request)
        except IOError:
            CORE.error(traceback.format_exc())
            self.client.disconnect()
            return

    def _closed(self):
        message = '\n'.join((
            'The connection to the Univention Management Console Server broke up unexpectedly. ',
            'If you have root permissions on the system you can restart UMC by executing the following commands:',
            ' * service univention-management-console-server restart',
            ' * service univention-management-console-web-server restart',
            'Otherwise please contact an administrator or try again later.',
        ))
        for id, (queue, request) in list(self._requestid2response_queue.items()):
            self._requestid2response_queue.pop(id)
            response = Response(request)
            response.status = SERVICE_UNAVAILABLE
            response.reason = 'UMC Service restarting'
            response.message = message
            queue.put(response)

    def cleanup_request(self, request):
        """Remove request from mapping."""
        self._lock.acquire()
        try:
            del self._requestid2response_queue[request.id]
        finally:
            self._lock.release()


class UMCP_Dispatcher(object):
    """Dispatcher used to exchange the requests between CherryPy and UMC"""

    # sessionid ==> SessionClient
    sessions = {}
    _queue_send = Queue.Queue()

    @classmethod
    @log_exceptions
    def check_queue(cls):
        while True:
            try:
                queuerequest = cls._queue_send.get_nowait()
            except Queue.Empty:
                # Queue is empty - nothing to do (for now)
                return True
            try:
                cls.dispatch(queuerequest)
            except Exception as exc:
                CORE.process('Failed to create UMC connection: %s' % (exc,))
                response = Response(queuerequest.request)
                response.status = 500
                response.message = traceback.format_exc()
                queuerequest.response_queue.put(response)

    @classmethod
    def dispatch(cls, queuerequest):
        CORE.info('UMCP_Dispatcher: check_queue: new request: 0x%x' % (id(queuerequest),))

        client = cls.sessions.get(queuerequest.sessionid)
        if client is None:
            try:
                client = SessionClient(ip=queuerequest.ip)
            except NoSocketError as exc:
                CORE.process('Failed to create UMC connection: %s' % (exc,))
                response = Response(queuerequest.request)
                response.status = SERVICE_UNAVAILABLE
                response.reason = 'UMC Service Unavailable'
                response.message = '\n'.join((
                    'The Univention Management Console Server is currently not running. ',
                    'If you have root permissions on the system you can restart it by executing the following command:',
                    ' * service univention-management-console-server restart',
                    'The following logfile may contain information why the server is not running:',
                    ' * /var/log/univention/management-console-server.log',
                    'Otherwise please contact an administrator or try again later.',
                ))
                queuerequest.response_queue.put(response)
                return

            cls.sessions[queuerequest.sessionid] = client
            callback = notifier.Callback(cls.cleanup_session, queuerequest.sessionid)
            client.client.signal_connect('closed', callback)

        # make sure a lost connection to the UMC-Server does not bind the session to ::1
        if client.ip in ('127.0.0.1', '::1') and queuerequest.ip != client.ip:
            CORE.warn('Switching session IP from=%r to=%r' % (client.ip, queuerequest.ip))
            client.ip = queuerequest.ip

        # bind session to IP (allow requests from localhost)
        if queuerequest.ip not in (client.ip, '127.0.0.1', '::1'):
            CORE.warn('The sessionid (ip=%s) is not valid for this IP address (%s)' % (client.ip, queuerequest.ip))
            response = Response(queuerequest.request)
            response.status = UNAUTHORIZED
            response.message = 'The current session is not valid with your IP address for security reasons. This might happen after switching the network. Please login again.'
            # very important! We must expire the session cookie, with the same path, otherwise one ends up in a infinite redirection loop after changing the IP address (e.g. because switching from VPN to regular network)
            for name in queuerequest.request.cookies:
                if name.startswith('UMCSessionId'):
                    response.cookies[name] = {
                        'expires': datetime.datetime.fromtimestamp(0),
                        'path': '/univention/',
                        'version': 1,
                        'value': '',
                    }
            queuerequest.response_queue.put(response)
            return

        if queuerequest.request.command == 'AUTH':
            CORE.info('Sending authentication request for user %r' % (queuerequest.request.body.get('username'),))

        client.send_request(queuerequest.request, queuerequest.response_queue)

    @classmethod
    def cleanup_session(cls, sessionid):
        """Removes a session when the connection to the UMC server has died or the session is expired"""
        try:
            del cls.sessions[sessionid]
            CORE.info('Cleaning up session %r' % (sessionid,))
        except KeyError:
            CORE.info('Session %r not found' % (sessionid,))


class UploadManager(dict):

    def add(self, request_id, store):
        tmpfile = tempfile.NamedTemporaryFile(prefix=request_id, dir=TEMPUPLOADDIR, delete=False)
        if hasattr(store, 'file') and store.file is None:
            tmpfile.write(store.value)
        else:
            tmpfile.write(store.file.read())
        tmpfile.close()
        if request_id in self:
            self[request_id].append(tmpfile.name)
        else:
            self[request_id] = [tmpfile.name]

        return tmpfile.name

    def cleanup(self, request_id):
        if request_id in self:
            filenames = self[request_id]
            for filename in filenames:
                if os.path.isfile(filename):
                    os.unlink(filename)
            del self[request_id]
            return True

        return False


_upload_manager = UploadManager()


class QueueRequest(object):
    """
    Element for the request queue containing the assoziated session
    ID, the request object, a response queue and the request ip address.

    :param sessionid: str
    :param request: ´univention.management.console.protocol.message.Request´
    :param response_queue: ´Queue.Queue´
    :param ip: str
    """

    __slots__ = ('sessionid', 'request', 'response_queue', 'ip')

    def __init__(self, sessionid, request, response_queue, ip):
        self.sessionid = sessionid
        self.request = request
        self.response_queue = response_queue
        self.ip = ip


class User(object):

    __slots__ = ('sessionid', 'username', 'password', 'saml', '_timeout', '_timeout_id')

    def __init__(self, sessionid, username, password, saml=None):
        self.sessionid = sessionid
        self.username = username
        self.password = password
        self.saml = saml
        self._timeout_id = None
        self.reset_timeout()

    def _session_timeout_timer(self):
        session = UMCP_Dispatcher.sessions.get(self.sessionid)
        if session and session._requestid2response_queue:
            self._timeout = 1
            self._timeout_id = notifier.timer_add(1000, self._session_timeout_timer)
            return

        CORE.info('session %r timed out' % (self.sessionid,))
        Ressource.sessions.pop(self.sessionid, None)
        self.on_logout()
        return False

    def reset_timeout(self):
        self.disconnect_timer()
        self._timeout = monotonic() + _session_timeout
        self._timeout_id = notifier.timer_add(int(self.session_end_time - monotonic()) * 1000, self._session_timeout_timer)

    def disconnect_timer(self):
        try:
            notifier.timer_remove(self._timeout_id)
        except KeyError:
            # timer has already been removed, Bug #52535
            CORE.warn('Session timer has already been removed for %r.' % (self,))

    def timed_out(self, now):
        return self.session_end_time < now

    @property
    def session_end_time(self):
        if self.is_saml_user() and self.saml.session_end_time:
            return self.saml.session_end_time
        return self._timeout

    def is_saml_user(self):
        # self.saml indicates that it was originally a
        # saml user. but it may have upgraded and got a
        # real password. the saml user object is still there,
        # though
        return self.password is None and self.saml

    def on_logout(self):
        self.disconnect_timer()
        if SAML.SP and self.saml:
            try:
                SAML.SP.local_logout(decode_name_id(self.saml.name_id))
            except Exception as exc:  # e.g. bsddb.DBNotFoundError
                CORE.warn('Could not remove SAML session: %s' % (exc,))

    def get_umc_password(self):
        if self.is_saml_user():
            return self.saml.message
        else:
            return self.password

    def get_umc_auth_type(self):
        if self.is_saml_user():
            return "SAML"
        else:
            return None

    def __repr__(self):
        return '<User(%s, %s, %s)>' % (self.username, self.sessionid, self.saml is not None)


class SAMLUser(object):

    __slots__ = ('message', 'username', 'session_end_time', 'name_id')

    def __init__(self, response, message):
        self.name_id = encode_name_id(response.name_id)
        self.message = message
        self.username = u''.join(response.ava['uid'])
        self.session_end_time = 0
        if response.not_on_or_after:
            self.session_end_time = int(monotonic() + (response.not_on_or_after - time.time()))


traceback_pattern = re.compile(r'(Traceback.*most recent call|File.*line.*in.*\d)')


@log_exceptions
def default_error_page(status, message, traceback, version, result=None):
    if not traceback and traceback_pattern.search(message):
        index = message.find('Traceback') if 'Traceback' in message else message.find('File')
        message, traceback = message[:index].strip(), message[index:].strip()
    if traceback:
        CORE.error('%s' % (traceback,))
    if ucr.is_false('umc/http/show_tracebacks', False):
        traceback = None

    accept_json, accept_html = 0, 0
    for accept in cherrypy.request.headers.elements('Accept'):
        mimetype = accept.value
        if mimetype in ('text/*', 'text/html'):
            accept_html = max(accept_html, accept.qvalue)
        if mimetype in ('application/*', 'application/json'):
            accept_json = max(accept_json, accept.qvalue)
    if accept_json < accept_html:
        return default_error_page_html(status, message, traceback, version, result)
    page = default_error_page_json(status, message, traceback, version, result)
    if 'X-Iframe-Response' in cherrypy.request.headers:
        cherrypy.response.headers['Content-Type'] = 'text/html'
        return '<html><body><textarea>%s</textarea></body></html>' % (escape(page, False),)
    return page


def default_error_page_html(status, message, traceback, version, result=None):
    content = default_error_page_json(status, message, traceback, version, result)
    try:
        with open('/usr/share/univention-management-console-frontend/error.html') as fd:
            content = fd.read().replace('%ERROR%', json.dumps(escape(content, True)))
        cherrypy.response.headers['Content-Type'] = 'text/html; charset=UTF-8'
    except (OSError, IOError):
        pass
    return content


def default_error_page_json(status, message, traceback, version, result=None):
    """The default error page for UMCP responses"""
    status, _, description = valid_status(status)
    if status == 401 and message == description:
        message = ''
    location = '%s/%s' % (cherrypy.request.base, cherrypy.request.uri[len(cherrypy.request.base) + 1:].split('/', 1)[0])
    if status == 404:
        traceback = None
    response = {
        'status': status,
        'message': message,
        'traceback': unescape(traceback) if traceback else traceback,
        'location': location,
    }
    if result:
        response['result'] = result
    cherrypy.response.headers['Content-type'] = 'application/json'
    return json.dumps(response)


class UMC_HTTPError(HTTPError):
    """HTTPError which sets a error result"""

    def __init__(self, status=500, message=None, body=None, error=None, reason=None):
        HTTPError.__init__(self, status if not reason else '%s %s' % (status, reason), message)
        self.body = body
        self.error = error

    def set_response(self):
        cherrypy._cperror.clean_headers(self.status)

        traceback = None
        if isinstance(self.error, dict) and self.error.get('traceback'):
            traceback = '%s\nRequest: %s\n\n%s' % (self._message, self.error.get('command'), self.error.get('traceback'))
            traceback = traceback.strip()
        cherrypy.response.status = self.status
        content = default_error_page(self.status, self._message, traceback, None, self.body)
        cherrypy.response.body = content.encode('utf-8')

        cherrypy.response.headers['Content-Length'] = len(content)

        cherrypy._cperror._be_ie_unfriendly(self.status)


class SamlError(UMC_HTTPError):

    def __init__(self, _=_):
        self._ = _

    def error(func=None, status=400):  # noqa: N805
        def _decorator(func):
            def _decorated(self, *args, **kwargs):
                message = func(self, *args, **kwargs) or ()
                super(SamlError, self).__init__(status, message)
                if "Passive authentication not supported." not in message:
                    # "Passive authentication not supported." just means an active login is required. That is expected and needs no logging. It still needs to be raised though.
                    CORE.warn('SamlError: %s %s' % (status, message))
                return self
            return _decorated
        if func is None:
            return _decorator
        return _decorator(func)

    def from_exception(self, etype, exc, etraceback):
        if isinstance(exc, DefusedXmlException):
            return self.defusedxml(exc)
        if isinstance(exc, ResponseLifetimeExceed):
            return self.response_lifetime_exceed(exc)
        if isinstance(exc, UnknownPrincipal):
            return self.unknown_principal(exc)
        if isinstance(exc, UnsupportedBinding):
            return self.unsupported_binding(exc)
        if isinstance(exc, VerificationError):
            return self.verification_error(exc)
        if isinstance(exc, UnsolicitedResponse):
            return self.unsolicited_response(exc)
        if isinstance(exc, StatusError):
            return self.status_error(exc)
        if isinstance(exc, MissingKey):
            return self.missing_key(exc)
        if isinstance(exc, SignatureError):
            return self.signature_error(exc)
        six.reraise(etype, exc, etraceback)

    @error
    def defusedxml(self, exc):
        CORE.error('Hacking attempt: %s' % (exc,))
        return self._('A hacking attempt was prevented.')

    @error
    def response_lifetime_exceed(self, exc):
        return self._('The response lifetime has exceeded: %s. Please make sure the server times are in sync.') % (exc,)

    @error
    def unknown_principal(self, exc):
        return self._('The principal is unknown: %s') % (exc,)

    @error
    def unsupported_binding(self, exc):
        return self._('The requested SAML binding is not known: %s') % (exc,)

    @error
    def unknown_logout_binding(self, binding):
        return self._('The logout binding is not known.')

    @error
    def verification_error(self, exc):
        return self._('The SAML response could not be verified: %s') % (exc,)

    @error
    def unsolicited_response(self, exc):
        return self._('Received an unsolicited SAML response. Please try to single sign on again by accessing /univention/saml/. Error message: %s') % (exc,)

    @error
    def status_error(self, exc):
        return self._('The identity provider reported a status error: %s') % (exc,)

    @error(status=500)
    def missing_key(self, exc):
        return self._('The issuer %r is now known to the SAML service provider. This is probably a misconfiguration and might be resolved by restarting the univention-management-console-web-server.') % (str(exc),)

    @error
    def signature_error(self, exc):
        return self._('The SAML response contained a invalid signature: %s') % (exc,)

    @error
    def unparsed_saml_response(self):
        return self._("The SAML message is invalid for this service provider.")

    @error(status=500)
    def no_identity_provider(self):
        return self._('There is a configuration error in the service provider: No identity provider are set up for use.')

    @error  # TODO: multiple choices redirection status
    def multiple_identity_provider(self, idps, idp_query_param):
        return self._('Could not pick an identity provider. You can specify one via the query string parameter %(param)r from %(idps)r') % {'param': idp_query_param, 'idps': idps}


class Ressource(object):

    # NOTE: only use CORE.process, _not_ CORE.error; since CORE.error writes as
    #       well to /var/log/syslog, this seems to cause problems with cherrypy.
    # (Bug #22634)
    _logOptions = {
        'error': CORE.process,
        'warn': CORE.warn,
        'info': CORE.info,
    }

    sessions = {}

    @property
    def name(self):
        """returns class name"""
        return self.__class__.__name__

    def _log(self, loglevel, _msg):
        remote = cherrypy.request.remote
        msg = '%s (%s:%s) %s' % (self.name, get_ip_address(), remote.port, _msg)
        self._logOptions.get(loglevel, lambda x: ud.debug(ud.MAIN, loglevel, x))(msg)

    def suffixed_cookie_name(self, name):
        host, _, port = cherrypy.request.headers.get('Host', '').partition(':')
        if port:
            try:
                port = '-%d' % (int(port),)
            except ValueError:
                port = ''
        return '%s%s' % (name, port)

    def create_sessionid(self, random=True):
        if self.get_session():
            # if the user is already authenticated at the UMC-Server
            # we must not change the session ID cookie as this might cause
            # race conditions in the frontend during login, especially when logged in via SAML
            return self.get_session_id()
        user = self.get_user()
        if user:
            # If the user was already authenticated at the UMC-Server
            # and the connection was lost (e.g. due to a module timeout)
            # we must not change the session ID cookie, as there might be multiple concurrent
            # requests from the same client during a new initialization of the connection to the UMC-Server.
            # They must cause that the session has one singleton connection!
            return user.sessionid
        if random:
            return str(uuid.uuid4())
        return sessionidhash()

    def get_session_id(self):
        """get the current session ID from cookie (or basic auth hash)."""
        # caution: use this function wisely: do not create a new session with this ID!
        # because it is an arbitrary value coming from the Client!
        return self.get_cookie('UMCSessionId') or sessionidhash()

    def get_session(self):
        return UMCP_Dispatcher.sessions.get(self.get_session_id())

    def check_saml_session_validity(self):
        user = self.get_user()
        if user and user.saml is not None and user.timed_out(monotonic()):
            raise UMC_HTTPError(UNAUTHORIZED)

    def set_cookies(self, *cookies, **kwargs):
        # TODO: use expiration from session timeout?
        # set the cookie once during successful authentication
        if kwargs.get('expires'):
            expires = kwargs.get('expires')
        elif ucr.is_true('umc/http/enforce-session-cookie'):
            # session cookie (will be deleted when browser closes)
            expires = None
        else:
            # force expiration of cookie in 5 years from now on...
            expires = (datetime.datetime.now() + datetime.timedelta(days=5 * 365))
        cookie = cherrypy.response.cookie
        for name, value in cookies:
            name = self.suffixed_cookie_name(name)
            cookie[name] = value
            if expires:
                cookie[name]['expires'] = expires.strftime("%a, %d-%b-%Y %H:%M:%S GMT")
            cookie[name]['version'] = 1
            cookie[name]['path'] = '/univention/'
            if cherrypy.request.scheme == 'https' and ucr.is_true('umc/http/enforce-secure-cookie'):
                cookie[name]['secure'] = True
            if ucr.get('umc/http/cookie/samesite') in ('Strict', 'Lax', 'None'):
                cookie[name]['samesite'] = ucr['umc/http/cookie/samesite']

    def get_cookie(self, name):
        cookie = cherrypy.request.cookie.get
        morsel = cookie(self.suffixed_cookie_name(name)) or cookie(name)
        if morsel:
            return morsel.value

    def set_session(self, sessionid, username, password=None, saml=None):
        olduser = self.get_user()
        if olduser:
            olduser.disconnect_timer()
        user = User(sessionid, username, password, saml or olduser and olduser.saml)
        self.sessions[sessionid] = user
        self.set_cookies(('UMCSessionId', sessionid), ('UMCUsername', username))
        return user

    def expire_session(self):
        sessionid = self.get_session_id()
        if sessionid:
            user = self.sessions.pop(sessionid, None)
            if user:
                user.on_logout()
            UMCP_Dispatcher.cleanup_session(sessionid)
            self.set_cookies(('UMCSessionId', ''), expires=datetime.datetime.fromtimestamp(0))

    def get_user(self):
        value = self.get_session_id()
        if not value or value not in self.sessions:
            return
        user = self.sessions[value]
        if user.timed_out(monotonic()):
            return
        return user


class CPgeneric(Ressource):

    def get_request(self, path, args):
        return Request(['generic'], opts={})

    def add_request_headers(self, request):
        request.http_method = cherrypy.request.method
        if six.PY2:
            request.headers = {name.decode('ISO8859-1').title(): value.decode('ISO8859-1') for name, value in cherrypy.request.headers.items()}
        else:
            request.headers = dict(cherrypy.request.headers.items())
        request.headers.pop('Cookie', None)
        if six.PY2:
            request.cookies = {x.key.decode('ISO8859-1'): x.value.decode('ISO8859-1') for x in cherrypy.request.cookie.values()}
        else:
            request.cookies = {x.key: x.value for x in cherrypy.request.cookie.values()}
        for name, value in list(request.cookies.items()):
            if name == self.suffixed_cookie_name('UMCSessionId'):
                request.cookies['UMCSessionId'] = value

    def add_response_headers(self, response):
        if response.headers:
            cherrypy.response.headers.update(response.headers)
        for key, item in response.cookies.items():
            if six.PY2 and not isinstance(key, bytes):
                key = key.encode('utf-8')  # bug in Python cookie!
            if isinstance(item, dict):
                cherrypy.response.cookie[key] = item.pop('value', '')
                cherrypy.response.cookie[key].update(item)
            else:
                cherrypy.response.cookie[key] = item
        if isinstance(response.body, dict):
            response.body.pop('headers', None)
            response.body.pop('cookies', None)

    def load_json(self, body):
        try:
            json_ = json.loads(body)
            if not isinstance(json_, dict):
                raise UMC_HTTPError(BAD_REQUEST, 'JSON document have to be dict')
        except ValueError:
            self._log('error', 'cannot parse JSON body')
            raise UMC_HTTPError(BAD_REQUEST, 'Invalid JSON document')
        return json_

    @cherrypy.expose
    def default(self, *path, **kwargs):
        self._log('info', 'got new request')
        self.check_saml_session_validity()
        return self.get_response(self.create_sessionid(), path, self.get_arguments(kwargs))

    def get_arguments(self, kwargs):
        if cherrypy.request.headers.get('Content-Type', '').startswith('application/json'):  # normal (json) request
            # get body and parse json
            body = u'{}'
            if cherrypy.request.method in cherrypy.request.methods_with_bodies:
                if not cherrypy.request.headers.get(u"Content-Length"):
                    self._log('warn', 'missing Content-Length header')
                    raise UMC_HTTPError(LENGTH_REQUIRED, 'Missing Content-Length header')
                body = cherrypy.request.body.read().decode('UTF-8', 'replace')

            args = self.load_json(body)
        else:
            # request is not json
            args = {'options': kwargs}
            if 'flavor' in kwargs:
                args['flavor'] = kwargs['flavor']
        return args

    def get_response(self, sessionid, path, args):
        # create new UMCP request
        req = self.get_request('/'.join(path), args)

        user = self.get_user()
        client = UMCP_Dispatcher.sessions.get(sessionid)
        if user and (user.password or user.saml) and (not client or not client.authenticated):
            auth = Request('AUTH')
            auth.body = {
                'username': user.username,
                'password': user.get_umc_password(),
                'auth_type': user.get_umc_auth_type(),
            }
            try:
                self.make_queue_request(sessionid, auth)
                self.set_session(sessionid, user.username, password=user.password)
            except UMC_HTTPError:
                self.expire_session()
                raise

        response = self.make_queue_request(sessionid, req)
        body = response.body
        if response.mimetype == 'application/json':
            body = json.dumps(response.body).encode('ASCII')

        return body

    def make_queue_request(self, sessionid, request):
        """Appends a UMCP request to the queue and waits/blocks until the response is available"""
        self.add_request_headers(request)
        self.set_accept_language(request)

        response_queue = Queue.Queue()
        user = self.get_user()

        queue_request = QueueRequest(sessionid, request, response_queue, get_ip_address())
        UMCP_Dispatcher._queue_send.put(queue_request)
        if user:
            user.reset_timeout()

        self._log(99, 'queue(0x%x): sessionid=%r' % (id(response_queue), sessionid))
        self._log('info', 'pushed request(0x%x) to queue(0x%x) - waiting for response' % (id(queue_request), id(response_queue)))
        response = response_queue.get()
        self._log('info', 'got response(0x%x) from queue(0x%x): status=%s' % (id(response), id(response_queue), response.status))

        self.add_response_headers(response)

        status = response.status or 200  # status is not set if not json
        if 200 <= status < 300:
            cherrypy.response.headers['Content-Type'] = response.mimetype
            cherrypy.response.status = status
            return response
        elif 300 <= status < 400:
            raise HTTPRedirect(response.headers.get('Location', ''), status)

        # something bad happened
        self._log('error', 'response status code: %s' % (response.status,))
        self._log('error', 'response reason : %s' % (response.reason,))
        self._log('error', 'response message: %s' % (response.message,))
        self._log('error', 'response result: %s' % (response.result,))
        if response.error:
            self._log('error', 'response error: %r' % (response.error,))
        raise UMC_HTTPError(response.status, response.message, response.result, response.error, response.reason)

    def set_accept_language(self, request):
        # set language based on Accept-Language header
        try:
            languages = [x.value for x in cherrypy.request.headers.elements('Accept-Language') if x.qvalue > 0]
        except Exception as exc:
            CORE.warn('malformed Accept-Language header: %s' % (exc,))
            languages = []

        # workaround for Safari (de-de -> de-DE): https://bugs.webkit.org/show_bug.cgi?id=163096
        languages = [re.sub('^([a-z][a-z]-)([a-z][a-z])$', lambda m: m.group(1) + m.group(2).upper(), lang) for lang in languages]

        # pre parse the HTTP syntax so that the UMC-Server doesn't need to do this (because there are no utility functions there)
        request.headers['Accept-Language'] = ', '.join(languages) or 'en-US'


class CPGet(CPgeneric):

    @cherrypy.expose
    def index(self, *args, **kwargs):
        raise UMC_HTTPError(NOT_FOUND)

    def get_request(self, path, args):
        return Request('GET', arguments=[path], options=args.get('options', {}))

    @cherrypy.expose
    def session_info(self, *args, **kwargs):
        info = {}
        user = self.get_user()
        if user is None:
            raise UMC_HTTPError(UNAUTHORIZED)
        info['username'] = user.username
        info['auth_type'] = user.saml and 'SAML'
        info['remaining'] = int(user.session_end_time - monotonic())
        return json.dumps({"status": 200, "result": info, "message": ""}).encode('ASCII')

    @cherrypy.expose
    def ipaddress(self, *a, **kw):
        try:
            addresses = self.addresses
        except ValueError:
            # hacking attempt
            addresses = [cherrypy.request.remote.ip]
        return json.dumps(addresses).encode('ASCII')

    @property
    def addresses(self):
        addresses = cherrypy.request.headers.get('X-FORWARDED-FOR', cherrypy.request.remote.ip).split(',') + [cherrypy.request.remote.ip]
        addresses = {ip_address(x.decode('ASCII', 'ignore').strip() if isinstance(x, bytes) else x.strip()) for x in addresses}
        addresses.discard(ip_address(u'::1'))
        addresses.discard(ip_address(u'127.0.0.1'))
        return tuple(address.exploded for address in addresses)


class CPSet(CPgeneric):

    def get_request(self, path, args):
        return Request('SET', options=args.get('options', {}))


class CPCommand(CPgeneric):

    def get_request(self, path, args):
        if self._is_file_upload():
            return self.get_request_upload(path, args)

        if not path:
            raise UMC_HTTPError(NOT_FOUND)

        req = Request('COMMAND', [path], options=args.get('options', {}))
        if 'flavor' in args:
            req.flavor = args['flavor']

        return req

    def get_response(self, sessionid, path, args):
        response = super(CPCommand, self).get_response(sessionid, path, args)

        # check if the request is a iframe upload
        if 'X-Iframe-Response' in cherrypy.request.headers:
            # this is a workaround to make iframe uploads work, they need the textarea field
            cherrypy.response.headers['Content-Type'] = 'text/html'
            return '<html><body><textarea>%s</textarea></body></html>' % (response)

        return response

    def get_request_upload(self, path, args):
        self._log('info', 'Handle upload command')
        cherrypy.request.headers['Accept'] = 'application/json'  # enforce JSON response in case of errors
        if 'iframe' in args and (args['iframe'] not in ('false', False, 0, '0')):
            cherrypy.request.headers['X-Iframe-Response'] = 'true'  # enforce textarea wrapping
        req = Request('UPLOAD', arguments=[path])
        req.body = self._get_upload_arguments(req, args)
        return req

    def get_arguments(self, kwargs):
        if self._is_file_upload():
            return kwargs
        return super(CPCommand, self).get_arguments(kwargs)

    def _is_file_upload(self):
        return cherrypy.request.headers.get('Content-Type', '').startswith('multipart/form-data')

    def _get_upload_arguments(self, req, args):
        options = []
        body = {}

        # check if enough free space is available
        min_size = get_int('umc/server/upload/min_free_space', 51200)  # kilobyte
        s = os.statvfs(TEMPUPLOADDIR)
        free_disk_space = s.f_bavail * s.f_frsize // 1024  # kilobyte
        if free_disk_space < min_size:
            self._log('error', 'there is not enough free space to upload files')
            raise UMC_HTTPError(BAD_REQUEST, 'There is not enough free space on disk')

        for iid, ifield in args.items():
            if isinstance(ifield, cherrypy._cpreqbody.Part):
                tmpfile = _upload_manager.add(req.id, ifield)
                options.append(self._sanitize_file(tmpfile, ifield))
            elif isinstance(ifield, list):
                # multiple files
                for jfield in ifield:
                    if isinstance(jfield, cherrypy._cpreqbody.Part):
                        tmpfile = _upload_manager.add(req.id, jfield)
                        options.append(self._sanitize_file(tmpfile, jfield))
                    else:
                        CORE.warn('Unknown type of multipart/form-data entry: %r=%r' % (iid, jfield))
            elif isinstance(ifield, six.string_types):
                # field is a string :)
                body[iid] = ifield
            else:
                CORE.warn('Unknown type of multipart/form-data entry: %r=%r' % (iid, ifield))

        body['options'] = options
        return body

    def _sanitize_file(self, tmpfile, store):
        # check if filesize is allowed
        st = os.stat(tmpfile)
        max_size = get_int('umc/server/upload/max', 64) * 1024
        if st.st_size > max_size:
            self._log('warn', 'file of size %d could not be uploaded' % (st.st_size))
            raise UMC_HTTPError(BAD_REQUEST, 'The size of the uploaded file is too large')

        filename = store.filename
        # some security
        for c in '<>/':
            filename = filename.replace(c, '_')

        return {
            'filename': filename,
            'name': store.name,
            'tmpfile': tmpfile,
        }


class CPAuth(CPgeneric):

    @cherrypy.config(**{'tools.umcp_auth.on': False})
    @cherrypy.expose
    def sso(self, *args, **kwargs):
        remote = cherrypy.request.remote
        CORE.info('CPAuth/auth/sso: got new auth request (%s:%s <=> %s)' % (get_ip_address(), remote.port, remote.name))

        user = self.get_user()
        if not user or not user.saml or user.timed_out(monotonic()):
            # redirect user to login page in case he's not authenticated or his session timed out
            raise HTTPRedirect('/univention/saml/')

        req = Request('AUTH')
        req.body = {
            "auth_type": "SAML",
            "username": user.username,
            "password": user.saml.message,
        }

        try:
            self._auth_request(req, user.sessionid)
        except UMC_HTTPError as exc:
            if exc.status == UNAUTHORIZED:
                # slapd down, time synchronization between IDP and SP is wrong, etc.
                CORE.error('SAML authentication failed: Make sure slapd runs and the time on the service provider and identity provider is identical.')
                raise UMC_HTTPError(
                    500,
                    'The SAML authentication failed. This might be a temporary problem. Please login again.\n'
                    'Further information can be found in the following logfiles:\n'
                    '* /var/log/univention/management-console-web-server.log\n'
                    '* /var/log/univention/management-console-server.log\n',
                )
            raise

        # protect against javascript:alert('XSS'), mailto:foo and other non relative links!
        location = urlparse(kwargs.get('return', '/univention/management/'))
        if location.path.startswith('//'):
            location = urlparse('')
        location = urlunsplit(('', '', location.path, location.query, location.fragment))
        cherrypy.response.status = 303
        cherrypy.response.headers['Location'] = location

    @cherrypy.config(**{'tools.umcp_auth.on': False})
    @cherrypy.expose
    def default(self, **kwargs):
        remote = cherrypy.request.remote
        CORE.info('CPAuth/auth: got new auth request (%s:%s <=> %s)' % (get_ip_address(), remote.port, remote.name))

        try:
            content_length = int(cherrypy.request.headers.get("Content-Length", 0))
        except ValueError:
            content_length = None
        if not content_length and content_length != 0:
            CORE.process('auth: missing Content-Length header')
            raise UMC_HTTPError(LENGTH_REQUIRED)

        if cherrypy.request.method in cherrypy.request.methods_with_bodies:
            max_length = 2000 * 1024
            if content_length >= max_length:  # prevent some DoS
                raise UMC_HTTPError(REQUEST_ENTITY_TOO_LARGE, 'Request data is too large, allowed length is %d' % max_length)

        data = self.get_arguments(kwargs)

        CORE.info('auth: request: command=%s' % cherrypy.request.path_info)

        # create a sessionid if the user is not yet authenticated
        sessionid = self.create_sessionid(True)

        # create new UMCP request
        req = Request('AUTH')
        req.body = data.get('options', {})
        req.body['auth_type'] = None
        return self._auth_request(req, sessionid)

    def _auth_request(self, req, sessionid):
        response = self.make_queue_request(sessionid, req)

        self._log(99, 'auth: creating session with sessionid=%r' % (sessionid,))
        CORE.process('auth_type=%r' % (req.body.get('auth_type'),))

        username = req.body.get('username')
        password = req.body.get('password')
        body = response.body
        if response.mimetype == 'application/json':
            username = body.get('result', {}).get('username', username)
            body = json.dumps(response.body).encode('UTF-8')
        self.set_session(sessionid, username, password=password)
        return body

    def basic(self):
        credentials = cherrypy.request.headers.get('Authorization')
        if not credentials:
            return
        sessionid = self.create_sessionid(False)
        if sessionid in UMCP_Dispatcher.sessions:
            return
        try:
            scheme, credentials = credentials.split(u' ', 1)
        except ValueError:
            return
        if scheme.lower() != u'basic':
            return
        try:
            username, password = base64.b64decode(credentials.encode('utf-8')).decode('latin-1').split(u':', 1)
        except ValueError:
            return

        # authenticate
        sessionid = sessionidhash()
        req = Request('AUTH')
        req.body = {
            "username": username,
            "password": password,
        }
        self._auth_request(req, sessionid)


class Root(Ressource):

    def __init__(self):
        self.command = self.upload = CPCommand()
        self.auth = CPAuth()
        self.get = CPGet()
        self.set = CPSet()
        self.saml = SAML()
        reload_webserver.callbacks.append(self.saml.reload)

    @cherrypy.expose
    def index(self, **kw):
        """http://localhost:<ucr:umc/http/port>/"""
        raise HTTPRedirect('/univention/', status=305)

    @cherrypy.expose
    def logout(self, **kwargs):
        user = self.get_user()
        if user and user.saml is not None:
            raise HTTPRedirect('/univention/saml/logout')
        self.expire_session()
        location = kwargs.get('location')
        if location:
            # protect against javascript:alert('XSS'), mailto:foo and other non relative links!
            location = urlparse(location)
            if location.path.startswith('//'):
                location = urlparse('')
            location = urlunsplit(('', '', location.path, location.query, location.fragment))
        location = location or ucr.get('umc/logout/location') or '/univention/'
        raise HTTPRedirect(location)


def get_ip_address():
    """get the IP address of client by last entry (from apache) in X-FORWARDED-FOR header"""
    return cherrypy.request.headers.get('X-FORWARDED-FOR', cherrypy.request.remote.ip).rsplit(', ', 1).pop()


class SAML(Ressource):

    SP = None
    identity_cache = '/var/cache/univention-management-console/saml-%d.bdb' if ucr.is_false('umc/saml/in-memory-identity-cache') else None
    state_cache = None
    configfile = '/usr/share/univention-management-console/saml/sp.py'
    idp_query_param = "IdpQuery"
    bindings = [BINDING_HTTP_REDIRECT, BINDING_HTTP_POST, BINDING_HTTP_ARTIFACT]
    outstanding_queries = {}

    def __init__(self):
        self.reload()

    @property
    def sp(self):
        if not self.SP and not self.reload():
            raise UMC_HTTPError(SERVICE_UNAVAILABLE, 'Single sign on is not available due to misconfiguration. See logfiles.', reason='SSO Service Unavailable')
        return self.SP

    @classmethod
    def reload(cls):
        CORE.info('Reloading SAML service provider configuration')
        sys.modules.pop(os.path.splitext(os.path.basename(cls.configfile))[0], None)
        identity_cache = cls.identity_cache % (PORT,) if cls.identity_cache else None
        try:
            cls.SP = Saml2Client(config_file=cls.configfile, identity_cache=identity_cache, state_cache=cls.state_cache)
            return True
        except Exception:
            CORE.warn('Startup of SAML2.0 service provider failed:\n%s' % (traceback.format_exc(),))
        return False

    @cherrypy.expose
    def metadata(self, *args, **kwargs):
        metadata = create_metadata_string(self.configfile, None, valid='4', cert=None, keyfile=None, mid=None, name=None, sign=False)
        cherrypy.response.headers['Content-Type'] = 'application/xml'
        return metadata

    @cherrypy.expose
    def index(self, *args, **kwargs):
        binding, message, relay_state = self._get_saml_message()

        if message is None:
            return self.do_single_sign_on(relay_state=kwargs.get('location', '/univention/management/'))

        acs = self.attribute_consuming_service
        if relay_state == 'iframe-passive':
            acs = self.attribute_consuming_service_iframe
        return acs(binding, message, relay_state)

    @cherrypy.expose
    def iframe(self, *args, **kwargs):
        cherrypy.request.uri = cherrypy.request.uri.replace('/iframe', '')
        return self.do_single_sign_on(is_passive='true', relay_state='iframe-passive')

    def attribute_consuming_service(self, binding, message, relay_state):
        response = self.acs(message, binding)
        saml = SAMLUser(response, message)
        user = self.set_session(self.create_sessionid(), saml.username, saml=saml)
        self.drop_umcp_authentication(user.sessionid)

        # protect against javascript:alert('XSS'), mailto:foo and other non relative links!
        location = urlparse(relay_state)
        if location.path.startswith('//'):
            location = urlparse('')
        location = urlunsplit(('', '', location.path, location.query, location.fragment))
        raise HTTPRedirect(location, 303)

    def attribute_consuming_service_iframe(self, binding, message, relay_state):
        cherrypy.request.headers['Accept'] = 'application/json'  # enforce JSON response in case of errors
        cherrypy.request.headers['X-Iframe-Response'] = 'true'  # enforce textarea wrapping
        response = self.acs(message, binding)
        saml = SAMLUser(response, message)
        sessionid = self.create_sessionid()
        self.set_session(sessionid, saml.username, saml=saml)
        self.drop_umcp_authentication(sessionid)
        cherrypy.response.headers['Content-Type'] = 'text/html'
        data = {"status": 200, "result": {"username": saml.username}}
        return b'<html><body><textarea>%s</textarea></body></html>' % (json.dumps(data).encode('ASCII'),)

    def drop_umcp_authentication(self, sessionid):
        """Force re-authentication if we get a new SAML message"""
        client = UMCP_Dispatcher.sessions.get(sessionid)
        if client:
            client.authenticated = False

    @cherrypy.expose
    def slo(self, *args, **kwargs):  # single logout service
        binding, message, relay_state = self._get_saml_message()
        if message is None:
            raise UMC_HTTPError(400, 'The HTTP request is missing required SAML parameter.')

        try:
            data = base64.b64decode(message.encode('UTF-8'))
            is_logout_request = b'LogoutRequest' in data or b'LogoutRequest' in zlib.decompress(data, -15).split(b'>', 1)[0]
        except Exception:
            CORE.error(traceback.format_exc())
            is_logout_request = False

        if is_logout_request:
            user = self.get_user()
            if not user or user.saml is None:
                # The user is either already logged out or has no cookie because he signed in via IP and gets redirected to the FQDN
                name_id = None
            else:
                name_id = user.saml.name_id
                user.saml = None
            http_args = self.sp.handle_logout_request(message, name_id, binding, relay_state=relay_state)
            self.expire_session()
            return self.http_response(binding, http_args)
        else:
            response = self.sp.parse_logout_request_response(message, binding)
            self.sp.handle_logout_response(response)
        return self._logout_success()

    @cherrypy.expose
    def logout(self, *args, **kwargs):
        user = self.get_user()

        if user is None or user.saml is None:
            return self._logout_success()

        # What if more than one
        try:
            data = self.sp.global_logout(user.saml.name_id)
        except KeyError:
            try:
                tb = sys.exc_info()[2]
                while tb.tb_next:
                    tb = tb.tb_next
                if tb.tb_frame.f_code.co_name != 'entities':
                    raise
            finally:
                tb = None
            # already logged out or UMC-Webserver restart
            user.saml = None
            data = {}

        for _entity_id, logout_info in data.items():
            if not isinstance(logout_info, tuple):
                continue  # result from logout, should be OK

            binding, http_args = logout_info
            if binding not in (BINDING_HTTP_POST, BINDING_HTTP_REDIRECT):
                raise SamlError().unknown_logout_binding(binding)

            return self.http_response(binding, http_args)
        return self._logout_success()

    def _logout_success(self):
        user = self.get_user()
        if user:
            user.saml = None
        raise HTTPRedirect('/univention/logout')

    def _get_saml_message(self):
        """Get the SAML message and corresponding binding from the HTTP request"""
        if cherrypy.request.method not in ('GET', 'POST'):
            cherrypy.response.headers['Allow'] = 'GET, HEAD, POST'
            raise UMC_HTTPError(405)

        if cherrypy.request.method == 'GET':
            binding = BINDING_HTTP_REDIRECT
            args = cherrypy.request.query
        elif cherrypy.request.method == "POST":
            binding = BINDING_HTTP_POST
            args = cherrypy.request.params

        relay_state = args.get('RelayState', '')
        try:
            message = args['SAMLResponse']
        except KeyError:
            try:
                message = args['SAMLRequest']
            except KeyError:
                try:
                    message = args['SAMLart']
                except KeyError:
                    return None, None, None
                message = self.sp.artifact2message(message, 'spsso')
                binding = BINDING_HTTP_ARTIFACT

        if isinstance(message, list):
            message = message[0]

        return binding, message, relay_state

    def acs(self, message, binding):  # attribute consuming service  # TODO: rename into parse
        try:
            response = self.sp.parse_authn_request_response(message, binding, self.outstanding_queries)
        except (UnknownPrincipal, UnsupportedBinding, VerificationError, UnsolicitedResponse, StatusError, MissingKey, SignatureError, ResponseLifetimeExceed, DefusedXmlException):
            raise SamlError().from_exception(*sys.exc_info())
        if response is None:
            CORE.warn('The SAML message could not be parsed with binding %r: %r' % (binding, message))
            raise SamlError().unparsed_saml_response()
        self.outstanding_queries.pop(response.in_response_to, None)
        return response

    def do_single_sign_on(self, **kwargs):
        binding, http_args = self.create_authn_request(**kwargs)
        return self.http_response(binding, http_args)

    def create_authn_request(self, **kwargs):
        """
        Creates the SAML <AuthnRequest> request and returns the SAML binding and HTTP response.

        Returns (binding, http-arguments)
        """
        identity_provider_entity_id = self.select_identity_provider()
        binding, destination = self.get_identity_provider_destination(identity_provider_entity_id)

        relay_state = kwargs.pop('relay_state', None)

        reply_binding, service_provider_url = self.select_service_provider()
        sid, message = self.sp.create_authn_request(destination, binding=reply_binding, assertion_consumer_service_urls=(service_provider_url,), **kwargs)

        http_args = self.sp.apply_binding(binding, message, destination, relay_state=relay_state)
        self.outstanding_queries[sid] = service_provider_url  # cherrypy.request.uri  # TODO: shouldn't this contain service_provider_url?
        return binding, http_args

    def select_identity_provider(self):
        """
        Select an identity provider based on the available identity providers.
        If multiple IDP's are set up the client might have specified one in the query string.
        Otherwise an error is raised where the user can choose one.

        Returns the EntityID of the IDP.
        """
        idps = self.sp.metadata.with_descriptor("idpsso")
        if not idps and self.reload():
            idps = self.sp.metadata.with_descriptor("idpsso")
        if self.idp_query_param in cherrypy.request.query and cherrypy.request.query[self.idp_query_param] in idps:
            return cherrypy.request.query[self.idp_query_param]
        if len(idps) == 1:
            return list(idps.keys())[0]
        if not idps:
            raise SamlError().no_identity_provider()
        raise SamlError().multiple_identity_provider(list(idps.keys()), self.idp_query_param)

    def get_identity_provider_destination(self, entity_id):
        """
        Get the destination (with SAML binding) of the specified entity_id.

        Returns (binding, destination-URI)
        """
        return self.sp.pick_binding("single_sign_on_service", self.bindings, "idpsso", entity_id=entity_id)

    def select_service_provider(self):
        """
        Select the ACS-URI and binding of this service provider based on the request uri.
        Tries to preserve the current scheme (HTTP/HTTPS) and netloc (host/IP) but falls back to FQDN if it is not set up.

        Returns (binding, service-provider-URI)
        """
        acs = self.sp.config.getattr("endpoints", "sp")["assertion_consumer_service"]
        service_url, reply_binding = acs[0]
        netloc = False
        p2 = urlparse(cherrypy.request.uri)
        for _url, _binding in acs:
            p1 = urlparse(_url)
            if p1.scheme == p2.scheme and p1.netloc == p2.netloc:
                netloc = True
                service_url, reply_binding = _url, _binding
                if p1.path == p2.path:
                    break
            elif not netloc and p1.netloc == p2.netloc:
                service_url, reply_binding = _url, _binding
        CORE.info('SAML: picked %r for %r with binding %r' % (service_url, cherrypy.request.uri, reply_binding))
        return reply_binding, service_url

    def http_response(self, binding, http_args):
        """Converts the HTTP arguments from pysaml2 into the cherrypy response."""
        body = u''.join(http_args["data"])
        for key, value in http_args["headers"]:
            cherrypy.response.headers[key] = value

        if binding in (BINDING_HTTP_ARTIFACT, BINDING_HTTP_REDIRECT):
            cherrypy.response.status = 303 if cherrypy.request.protocol >= (1, 1) and cherrypy.request.method == 'POST' else 302
            if not body:
                raise HTTPRedirect(cherrypy.response.headers['Location'], status=cherrypy.response.status)

        return body.encode('UTF-8')


@log_exceptions
def run_cherrypy(options):
    # TODO FIXME Folgenden Configeintrag einbauen, wenn loglevel in (0,1,2)
    # 'server.environment': 'production',
    root = Root()
    cherrypy.tools.umcp_auth = cherrypy.Tool('before_handler', root.auth.basic, priority=1)
    cherrypy.config.update({
        'log.screen': True,
        'server.socket_port': options.port,
        'server.socket_host': ucr.get('umc/http/interface', '127.0.0.1'),
        'server.request_queue_size': get_int('umc/http/requestqueuesize', 100),
        'server.thread_pool': get_int('umc/http/maxthreads', 35),
        'server.max_request_body_size': get_int('umc/http/max_request_body_size', 104857600),
        'response.timeout': get_int('umc/http/response-timeout', 310),
        'engine.autoreload.on': False,
        'tools.response_headers.on': True,
        'tools.response_headers.headers': [
            ('Content-Type', 'application/json'),
        ],
        'tools.fix_uri.on': True,
        'tools.umcp_auth.on': True,
        'error_page.default': default_error_page,
    })

    n = SystemdNotifier()
    n.notify("READY=1")
    cherrypy.quickstart(root=root)


def reload_webserver():
    for func in reload_webserver.callbacks:
        func()


reload_webserver.callbacks = [log_reopen]


class UMC_HTTP_Daemon(object):

    def __init__(self):
        self.parser = ArgumentParser()
        self.parser.add_argument(
            '-d', '--debug', type=int, default=ucr.get_int('umc/server/debug/level', 1),
            help='if given than debugging is activated and set to the specified level [default: %(default)s]',
        )
        self.parser.add_argument(
            '-L', '--log-file', default='management-console-web-server',
            help='specifies an alternative log file [default: %(default)s]',
        )
        self.parser.add_argument(
            '-p', '--port', type=int,
            default=get_int('umc/http/port', 8090),
            help='defines an alternative port number [default %(default)s]')
        self.options = self.parser.parse_args()

        processes = get_int('umc/http/processes', 1)

        # init logging
        log_init(self.options.log_file, self.options.debug, processes > 1)

        global PORT
        PORT = self.options.port

    def run(self):
        # cherrypy runs in a thread. signals can only be registered in the main thread
        # to prevent race conditions this must be called before the cherrypy thread gets created
        cherrypy.engine.signal_handler.handlers['SIGHUP'] = log_reopen
        cherrypy.engine.signal_handler.handlers['SIGUSR1'] = reload_webserver
        cherrypy.engine.signal_handler.subscribe()
        cherrypy.engine.subscribe('exit', lambda: notifier.dispatcher_add(lambda: sys.exit(0)))

        # start webserver as separate thread
        _thread_http = threading.Thread(target=run_cherrypy, args=(self.options,))
        _thread_http.deamon = True
        _thread_http.start()
        try:
            fd_limit = get_int('umc/http/max-open-file-descriptors', 65535)
            resource.setrlimit(resource.RLIMIT_NOFILE, (fd_limit, fd_limit))
        except (ValueError, resource.error) as exc:
            CORE.error('Could not raise NOFILE resource limits: %s' % (exc,))

        try:
            # start notifier loop
            notifier.init(notifier.GENERIC)
            notifier.dispatch.MIN_TIMER = get_int('umc/http/dispatch-interval', notifier.dispatch.MIN_TIMER)
            notifier.dispatcher_add(UMCP_Dispatcher.check_queue)
            notifier.loop()
        except (SystemExit, KeyboardInterrupt) as exc:
            # stop the web server
            CORE.info('stopping cherrypy: %s' % (exc,))
            cherrypy.engine.exit()
            CORE.info('cherrypy stopped')
        except BaseException:
            CORE.error('FATAL error: %s' % (traceback.format_exc(),))
            cherrypy.engine.exit()
            raise


if __name__ == '__main__':
    http_daemon = UMC_HTTP_Daemon()
    http_daemon.run()
