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