# -*- coding: utf-8 -*- # SPDX-License-Identifier: MIT """Auth system based on the client certificate and FAS account""" import json import os from socket import gethostname import ssl import requests import kerberos from flask import Response, g # Starting with Flask 0.9, the _app_ctx_stack is the correct one, # before that we need to use the _request_ctx_stack. try: from flask import _app_ctx_stack as stack except ImportError: from flask import _request_ctx_stack as stack from werkzeug.exceptions import Unauthorized as FlaskUnauthorized from dogpile.cache import make_region from module_build_service.errors import Unauthorized, Forbidden from module_build_service import app, log, conf try: import ldap3 except ImportError: log.warning("ldap3 import not found. ldap/krb disabled.") client_secrets = None region = make_region().configure("dogpile.cache.memory") def _json_loads(content): if not isinstance(content, str): content = content.decode("utf-8") return json.loads(content) def _load_secrets(): global client_secrets if client_secrets: return if "OIDC_CLIENT_SECRETS" not in app.config: raise Forbidden("OIDC_CLIENT_SECRETS must be set in server config.") secrets = _json_loads(open(app.config["OIDC_CLIENT_SECRETS"], "r").read()) client_secrets = list(secrets.values())[0] def _get_token_info(token): """ Asks the token_introspection_uri for the validity of a token. """ if not client_secrets: return None request = { "token": token, "token_type_hint": "Bearer", "client_id": client_secrets["client_id"], "client_secret": client_secrets["client_secret"], } headers = {"Content-type": "application/x-www-form-urlencoded"} resp = requests.post(client_secrets["token_introspection_uri"], data=request, headers=headers) return resp.json() def _get_user_info(token): """ Asks the userinfo_uri for more information on a user. """ if not client_secrets: return None headers = {"authorization": "Bearer " + token} resp = requests.get(client_secrets["userinfo_uri"], headers=headers) return resp.json() def get_user_oidc(request): """ Returns the client's username and groups based on the OIDC token provided. """ _load_secrets() if "authorization" not in request.headers: raise Unauthorized("No 'authorization' header found.") header = request.headers["authorization"].strip() prefix = "Bearer " if not header.startswith(prefix): raise Unauthorized("Authorization headers must start with %r" % prefix) token = header[len(prefix):].strip() try: data = _get_token_info(token) except Exception as e: error = "Cannot verify OIDC token: %s" % str(e) log.exception(error) raise Exception(error) if not data or "active" not in data or not data["active"]: raise Unauthorized("OIDC token invalid or expired.") if "OIDC_REQUIRED_SCOPE" not in app.config: raise Forbidden("OIDC_REQUIRED_SCOPE must be set in server config.") presented_scopes = data["scope"].split(" ") required_scopes = [ "openid", "https://id.fedoraproject.org/scope/groups", app.config["OIDC_REQUIRED_SCOPE"], ] for scope in required_scopes: if scope not in presented_scopes: raise Unauthorized("Required OIDC scope %r not present: %r" % (scope, presented_scopes)) try: extended_data = _get_user_info(token) except Exception: error = "OpenIDC auth error: Cannot determine the user's groups" log.exception(error) raise Unauthorized(error) username = data["username"] # If the user is part of the whitelist, then the group membership check is skipped if username in conf.allowed_users: groups = set() else: try: groups = set(extended_data["groups"]) except Exception: error = "Could not find groups in UserInfo from OIDC" log.exception("%s (extended_data: %s)", error, extended_data) raise Unauthorized(error) return username, groups # Insired by https://pagure.io/waiverdb/blob/master/f/waiverdb/auth.py which was # inspired by https://github.com/mkomitee/flask-kerberos/blob/master/flask_kerberos.py class KerberosAuthenticate(object): def __init__(self): if conf.kerberos_http_host: hostname = conf.kerberos_http_host else: hostname = gethostname() self.service_name = "HTTP@{0}".format(hostname) # If the config specifies a keytab to use, then override the KRB5_KTNAME # environment variable if conf.kerberos_keytab: os.environ["KRB5_KTNAME"] = conf.kerberos_keytab if "KRB5_KTNAME" in os.environ: try: principal = kerberos.getServerPrincipalDetails("HTTP", hostname) except kerberos.KrbError as error: raise Unauthorized('Kerberos: authentication failed with "{0}"'.format(str(error))) log.debug('Kerberos: server is identifying as "{0}"'.format(principal)) else: raise Unauthorized( 'Kerberos: set the config value of "KERBEROS_KEYTAB" or the ' 'environment variable "KRB5_KTNAME" to your keytab file' ) def _gssapi_authenticate(self, token): """ Performs GSSAPI Negotiate Authentication On success also stashes the server response token for mutual authentication at the top of request context with the name kerberos_token, along with the authenticated user principal with the name kerberos_user. """ state = None ctx = stack.top try: rc, state = kerberos.authGSSServerInit(self.service_name) if rc != kerberos.AUTH_GSS_COMPLETE: log.error("Kerberos: unable to initialize server context") return None rc = kerberos.authGSSServerStep(state, token) if rc == kerberos.AUTH_GSS_COMPLETE: log.debug("Kerberos: completed GSSAPI negotiation") ctx.kerberos_token = kerberos.authGSSServerResponse(state) ctx.kerberos_user = kerberos.authGSSServerUserName(state) return rc elif rc == kerberos.AUTH_GSS_CONTINUE: log.debug("Kerberos: continuing GSSAPI negotiation") return kerberos.AUTH_GSS_CONTINUE else: log.debug("Kerberos: unable to step server context") return None except kerberos.GSSError as error: log.error("Kerberos: unable to authenticate: {0}".format(str(error))) return None finally: if state: kerberos.authGSSServerClean(state) def process_request(self, token): """ Authenticates the current request using Kerberos. """ kerberos_user = None kerberos_token = None ctx = stack.top rc = self._gssapi_authenticate(token) if rc == kerberos.AUTH_GSS_COMPLETE: kerberos_user = ctx.kerberos_user kerberos_token = ctx.kerberos_token elif rc != kerberos.AUTH_GSS_CONTINUE: raise Forbidden("Invalid Kerberos ticket") return kerberos_user, kerberos_token def get_user_kerberos(request): user = None if "Authorization" not in request.headers: response = Response("Unauthorized", 401, {"WWW-Authenticate": "Negotiate"}) exc = FlaskUnauthorized() # For some reason, certain versions of werkzeug raise an exception when passing `response` # in the constructor. This is a work-around. exc.response = response raise exc header = request.headers.get("Authorization") token = "".join(header.strip().split()[1:]) user, kerberos_token = KerberosAuthenticate().process_request(token) # Remove the realm user = user.split("@")[0] # If the user is part of the whitelist, then the group membership check is skipped if user in conf.allowed_users: groups = [] else: groups = get_ldap_group_membership(user) return user, set(groups) @region.cache_on_arguments() def get_ldap_group_membership(uid): """ Small wrapper on getting the group membership so that we can use caching :param uid: a string of the uid of the user :return: a list of groups the user is a member of """ ldap_con = Ldap() return ldap_con.get_user_membership(uid) class Ldap(object): """ A class that handles LDAP connections and queries """ connection = None base_dn = None def __init__(self): if not conf.ldap_uri: raise Forbidden("LDAP_URI must be set in server config.") if conf.ldap_groups_dn: self.base_dn = conf.ldap_groups_dn else: raise Forbidden("LDAP_GROUPS_DN must be set in server config.") if conf.ldap_uri.startswith("ldaps://"): tls = ldap3.Tls( ca_certs_file="/etc/pki/tls/certs/ca-bundle.crt", validate=ssl.CERT_REQUIRED) server = ldap3.Server(conf.ldap_uri, use_ssl=True, tls=tls) else: server = ldap3.Server(conf.ldap_uri) self.connection = ldap3.Connection(server) try: self.connection.open() except ldap3.core.exceptions.LDAPSocketOpenError as error: log.error( 'The connection to "{0}" failed. The following error was raised: {1}'.format( conf.ldap_uri, str(error))) raise Forbidden( "The connection to the LDAP server failed. Group membership couldn't be obtained.") def get_user_membership(self, uid): """ Gets the group membership of a user :param uid: a string of the uid of the user :return: a list of common names of the posixGroups the user is a member of """ ldap_filter = "(memberUid={0})".format(uid) # Only get the groups in the base container/OU self.connection.search( self.base_dn, ldap_filter, search_scope=ldap3.LEVEL, attributes=["cn"]) groups = self.connection.response try: return [group["attributes"]["cn"][0] for group in groups] except KeyError: log.exception( "The LDAP groups could not be determined based on the search results " 'of "{0}"'.format(str(groups))) return [] def get_user(request): """ Authenticates the user and returns the username and group name :param request: a Flask request :return: a tuple with a string representing the user name and a set with the user's group membership such as ('mprahl', {'factory2', 'devel'}) """ if conf.no_auth is True: log.debug("Authorization is disabled.") return "anonymous", {"packager"} if "user" not in g and "groups" not in g: get_user_func_name = "get_user_{0}".format(conf.auth_method) get_user_func = globals().get(get_user_func_name) if not get_user_func: raise RuntimeError('The function "{0}" is not implemented'.format(get_user_func_name)) g.user, g.groups = get_user_func(request) return g.user, g.groups