import falcon
import binascii
import click
import gssapi
import ldap
import os
import random
import string
from asn1crypto import pem, x509
from base64 import b64decode
from falcon.util import http_date_to_dt
from datetime import datetime, timedelta
from pinecrypt.server.user import User
from pinecrypt.server import const, errors, db
from prometheus_client import Counter, Histogram
from pinecrypt.server.mongolog import LogHandler

clock_skew = Histogram(
    "pinecrypt_gateway_clock_skew",
    "Histogram of client-server clock skew.",
    ["method", "path", "passed"],
    buckets=(0.1, 0.5, 1.0, 5.0, 10.0, 50.0, 100.0, 500.0, 1000.0, 5000.0))
whitelist_blocked_requests = Counter(
    "pinecrypt_gateway_whitelist_blocked_requests",
    "Requests blocked by whitelists.",
    ["method", "path"])

logger = LogHandler()


def whitelist_subnets(subnets):
    """
    Validate source IP address of API call against subnet list
    """
    def wrapper(func):
        def wrapped(self, req, resp, *args, **kwargs):
            # Check for administration subnet whitelist
            for subnet in subnets:
                if req.context["remote"]["addr"] in subnet:
                    break
            else:
                logger.info("Rejected access to administrative call %s by %s from %s, source address not whitelisted",
                    req.env["PATH_INFO"],
                    req.context.get("user", "unauthenticated user"),
                    req.context["remote"]["addr"])
                whitelist_blocked_requests.labels(method=req.method, path=req.path).inc()
                raise falcon.HTTPForbidden("Forbidden", "Remote address %s not whitelisted" % req.context["remote"]["addr"])

            return func(self, req, resp, *args, **kwargs)
        return wrapped
    return wrapper


def whitelist_content_types(*content_types):
    def wrapper(func):
        def wrapped(self, req, resp, *args, **kwargs):
            for content_type in content_types:
                if req.get_header("Content-Type") == content_type:
                    return func(self, req, resp, *args, **kwargs)
            raise falcon.HTTPUnsupportedMediaType(
                "This API call accepts only %s content type" % ", ".join(content_types))
        return wrapped
    return wrapper


def whitelist_subject(func):
    def wrapped(self, req, resp, id, *args, **kwargs):
        from pinecrypt.server import authority
        try:
            cert, cert_doc, pem_buf = authority.get_signed(id)
        except errors.CertificateDoesNotExist:
            raise falcon.HTTPNotFound()
        else:
            buf = req.get_header("X-SSL-CERT")
            if buf:
                header, _, der_bytes = pem.unarmor(buf.replace("\t", "").encode("ascii"))
                origin_cert = x509.Certificate.load(der_bytes)
                if origin_cert.native == cert.native:
                    logger.debug("Subject authenticated using certificates")
                    return func(self, req, resp, id, *args, **kwargs)
            raise falcon.HTTPForbidden("Forbidden", "Remote address %s not whitelisted" % req.context["remote"]["addr"])
    return wrapped


def authenticate(optional=False):
    def wrapper(func):
        def wrapped(resource, req, resp, *args, **kwargs):
            kerberized = False

            if "kerberos" in const.AUTHENTICATION_BACKENDS:
                for subnet in const.KERBEROS_SUBNETS:
                    if req.context["remote"]["addr"] in subnet:
                        kerberized = True

            if not req.auth:  # no credentials provided
                if optional:  # optional allowed
                    req.context["user"] = None
                    return func(resource, req, resp, *args, **kwargs)

                if kerberized:
                    logger.debug("No Kerberos ticket offered while attempting to access %s from %s",
                        req.env["PATH_INFO"], req.context["remote"]["addr"])
                    raise falcon.HTTPUnauthorized("Unauthorized",
                        "No Kerberos ticket offered, are you sure you've logged in with domain user account?",
                        challenges=["Negotiate"])
                else:
                    logger.debug("No credentials offered while attempting to access %s from %s",
                        req.env["PATH_INFO"], req.context["remote"]["addr"])
                    raise falcon.HTTPUnauthorized("Unauthorized", "Please authenticate", challenges=["Basic"])

            if kerberized:
                if not req.auth.startswith("Negotiate "):
                    raise falcon.HTTPUnauthorized("Unauthorized",
                        "Bad header, expected Negotiate", challenges=["Negotiate"])

                os.environ["KRB5_KTNAME"] = const.KERBEROS_KEYTAB

                try:
                    server_creds = gssapi.creds.Credentials(
                        usage="accept",
                        name=gssapi.names.Name("HTTP/%s" % const.FQDN))
                except gssapi.raw.exceptions.BadNameError:
                    logger.error("Failed initialize HTTP service principal, possibly bad permissions for %s or /etc/krb5.conf" %
                        const.KERBEROS_KEYTAB)
                    raise

                context = gssapi.sec_contexts.SecurityContext(creds=server_creds)

                token = "".join(req.auth.split()[1:])

                try:
                    context.step(b64decode(token))
                except binascii.Error:
                    # base64 errors
                    raise falcon.HTTPBadRequest(title="Bad request", description="Malformed token")
                except gssapi.raw.exceptions.BadMechanismError:
                    raise falcon.HTTPBadRequest(title="Bad request", description="""
                        Unsupported authentication mechanism (NTLM?) was offered.
                        Please make sure you've logged into the computer with domain user account.
                        The web interface should not prompt for username or password.""")

                try:
                    username, realm = str(context.initiator_name).split("@")
                except AttributeError:
                    # TODO: Better exception handling
                    raise falcon.HTTPForbidden("Failed to determine username, are you trying to log in with correct domain account?")

                assert const.KERBEROS_REALM, "KERBEROS_REALM not configured"
                if realm != const.KERBEROS_REALM:
                    raise falcon.HTTPForbidden("Forbidden",
                        "Cross-realm trust not supported")

                if username.endswith("$") and optional:
                    # Extract machine hostname
                    # TODO: Assert LDAP group membership
                    req.context["machine"] = username[:-1].lower()
                    req.context["user"] = None
                else:
                    # Attempt to look up real user
                    req.context["user"] = User.objects.get(username)

                logger.debug("Succesfully authenticated user %s for %s from %s",
                    req.context["user"], req.env["PATH_INFO"], req.context["remote"]["addr"])
                return func(resource, req, resp, *args, **kwargs)

            else:
                if not req.auth.startswith("Basic "):
                    raise falcon.HTTPUnauthorized("Unauthorized", "Bad header, expected Basic", challenges=["Basic"])

                basic, token = req.auth.split(" ", 1)
                user, passwd = b64decode(token).decode("utf-8").split(":", 1)

            if "ldap" in const.AUTHENTICATION_BACKENDS:
                upn = ("%s@%s" % (user, const.KERBEROS_REALM)).lower()
                click.echo("Connecting to %s as %s" % (const.LDAP_AUTHENTICATION_URI, upn))
                conn = ldap.initialize(const.LDAP_AUTHENTICATION_URI, bytes_mode=False)
                conn.set_option(ldap.OPT_REFERRALS, 0)

                try:
                    conn.simple_bind_s(upn, passwd)
                except ldap.STRONG_AUTH_REQUIRED:
                    logger.critical("LDAP server demands encryption, use ldaps:// instead of ldap://")
                    raise
                except ldap.SERVER_DOWN:
                    logger.critical("Failed to connect LDAP server at %s, are you sure LDAP server's CA certificate has been copied to this machine?",
                        const.LDAP_AUTHENTICATION_URI)
                    raise
                except ldap.INVALID_CREDENTIALS:
                    logger.critical("LDAP bind authentication failed for user %s from  %s",
                        repr(upn), req.context["remote"]["addr"])
                    raise falcon.HTTPUnauthorized("Unauthorized",
                        description="Please authenticate with %s domain account username" % const.KERBEROS_REALM,
                        challenges=["Basic"])

                req.context["ldap_conn"] = conn
            else:
                raise NotImplementedError("No suitable authentication method configured")

            try:
                req.context["user"] = User.objects.get(user)
            except User.DoesNotExist:
                raise falcon.HTTPUnauthorized("Unauthorized", "Invalid credentials", challenges=["Basic"])

            retval = func(resource, req, resp, *args, **kwargs)
            if conn:
                conn.unbind_s()
            return retval
        return wrapped
    return wrapper


def login_required(func):
    return authenticate()(func)


def login_optional(func):
    return authenticate(optional=True)(func)


def authorize_admin(func):
    @whitelist_subnets(const.ADMIN_SUBNETS)
    def wrapped(resource, req, resp, *args, **kwargs):
        if req.context.get("user").is_admin():
            return func(resource, req, resp, *args, **kwargs)
        logger.info("User '%s' not authorized to access administrative API", req.context.get("user").name)
        raise falcon.HTTPForbidden("Forbidden", "User not authorized to perform administrative operations")
    return wrapped


def authorize_server(func):
    """
    Make sure the request originator has a certificate with server flags
    """
    from asn1crypto import pem, x509

    def wrapped(resource, req, resp, *args, **kwargs):
        buf = req.get_header("X-SSL-CERT")
        if not buf:
            logger.info("No TLS certificate presented to access administrative API call from %s" % req.context["remote"]["addr"])
            raise falcon.HTTPForbidden("Forbidden", "Machine not authorized to perform the operation")

        header, _, der_bytes = pem.unarmor(buf.replace("\t", "").encode("ascii"))
        cert = x509.Certificate.load(der_bytes)
        # TODO: validate serial
        for extension in cert["tbs_certificate"]["extensions"]:
            if extension["extn_id"].native == "extended_key_usage":
                if "server_auth" in extension["extn_value"].native:
                    req.context["machine"] = cert.subject.native["common_name"]
                    return func(resource, req, resp, *args, **kwargs)
        logger.info("TLS authenticated machine '%s' not authorized to access administrative API", cert.subject.native["common_name"])
        raise falcon.HTTPForbidden("Forbidden", "Machine not authorized to perform the operation")
    return wrapped


def validate_clock_skew(func):
    def wrapped(resource, req, resp, *args, **kwargs):
        try:
            skew = abs((http_date_to_dt(req.headers["DATE"]) - datetime.utcnow()))
        except KeyError:
            raise falcon.HTTPBadRequest(title="Bad request", description="No date information specified in header")

        passed = skew < const.CLOCK_SKEW_TOLERANCE
        clock_skew.labels(method=req.method, path=req.path, passed=int(passed)).observe(skew.total_seconds())
        if passed:
            return func(resource, req, resp, *args, **kwargs)
        else:
            raise falcon.HTTPBadRequest(title="Bad request", description="Clock skew too large")
    return wrapped


def cookie_login(func):
    def wrapped(resource, req, resp, *args, **kwargs):
        now = datetime.utcnow()
        value = req.get_cookie_values(const.SESSION_COOKIE)
        db.sessions.update_one({
            "secret": value,
            "started": {
                "$lte": now
            },
            "expires": {
                "$gte": now
            },
        }, {
            "$set": {
                "last_seen": now,
            }
        })
        return func(resource, req, resp, *args, **kwargs)
    return wrapped


def generate_password(length):
    letters = string.ascii_lowercase + string.ascii_uppercase + string.digits
    return "".join(random.choice(letters) for i in range(length))


def register_session(func):
    def wrapped(resource, req, resp, *args, **kwargs):
        now = datetime.utcnow()
        value = generate_password(50)
        db.sessions.insert({
            "user": req.context["user"].name,
            "secret": value,
            "last_seen": now,
            "started": now,
            "expires": now + timedelta(seconds=const.SESSION_AGE),
            "remote": str(req.context["remote"]),
        })
        resp.set_cookie(const.SESSION_COOKIE, value,
            max_age=const.SESSION_AGE)
        return func(resource, req, resp, *args, **kwargs)
    return wrapped
