diff --git a/Containerfile.test b/Containerfile.test index be1da4ad..ee442ff7 100644 --- a/Containerfile.test +++ b/Containerfile.test @@ -45,6 +45,11 @@ RUN dnf -y update && dnf -y install \ realmd \ freeipa-client \ oddjob-mkhomedir \ + mod_auth_gssapi \ + mod_session \ + gssproxy \ + openssh-clients \ + sshpass \ && dnf clean all # Copy the source code @@ -89,6 +94,12 @@ RUN chmod 740 /www/ipa-tuura/src/ipa-tuura/ RUN chown apache:apache /www/ipa-tuura/src/ipa-tuura/ RUN chown apache:apache /www/ipa-tuura/src/ipa-tuura/db.sqlite3 +# Setup gssproxy +COPY prod/conf/gssproxy.conf /etc/gssproxy/80-httpd.conf +COPY prod/conf/httpd_env.conf /etc/systemd/system/httpd.service.d/env.conf +RUN mkdir /var/lib/ipatuura +RUN systemctl enable gssproxy + # Enable httpd service RUN systemctl enable httpd diff --git a/prod/Containerfile b/prod/Containerfile index 17ae1da2..2bca2f5e 100644 --- a/prod/Containerfile +++ b/prod/Containerfile @@ -49,6 +49,11 @@ RUN dnf -y update && dnf -y install \ realmd \ freeipa-client \ oddjob-mkhomedir \ + mod_auth_gssapi \ + mod_session \ + gssproxy \ + openssh-clients \ + sshpass \ && dnf clean all # Copy the source code @@ -90,6 +95,13 @@ RUN chmod 740 /www/ipa-tuura/src/ipa-tuura/ RUN chown apache:apache /www/ipa-tuura/src/ipa-tuura/ RUN chown apache:apache /www/ipa-tuura/src/ipa-tuura/db.sqlite3 +# Setup gssproxy +COPY prod/conf/gssproxy.conf /etc/gssproxy/80-httpd.conf +COPY prod/conf/httpd_env.conf /etc/systemd/system/httpd.service.d/env.conf +RUN mkdir /var/lib/ipatuura +RUN chmod 770 /var/lib/ipatuura +RUN systemctl enable gssproxy + # Enable httpd service RUN systemctl enable httpd diff --git a/prod/conf/gssproxy.conf b/prod/conf/gssproxy.conf new file mode 100644 index 00000000..9b1bbd7e --- /dev/null +++ b/prod/conf/gssproxy.conf @@ -0,0 +1,5 @@ +[service/HTTP] + mechs = krb5 + cred_store = keytab:/var/lib/ipatuura/httpd.keytab + cred_store = ccache:/var/lib/gssproxy/clients/krb5cc_%U + euid = apache diff --git a/prod/conf/httpd_env.conf b/prod/conf/httpd_env.conf new file mode 100644 index 00000000..792ee7cf --- /dev/null +++ b/prod/conf/httpd_env.conf @@ -0,0 +1,2 @@ +[Service] +Environment=GSS_USE_PROXY=1 diff --git a/prod/conf/ipatuura.conf b/prod/conf/ipatuura.conf index 2fac5bfb..453b661f 100644 --- a/prod/conf/ipatuura.conf +++ b/prod/conf/ipatuura.conf @@ -2,6 +2,19 @@ LogLevel info RewriteCond %{SERVER_PORT} !^443$$ + # Skip mod_wsgi handling for GSSAPI auth endpoint + Alias /bridge/login_kerberos/ /dev/null + + AuthType GSSAPI + AuthName "Kerberos Login" + GssapiUseSessions On + Session On + SessionCookieName session path=/bridge;httponly;secure; + SessionHeader SESSION + GssapiSessionKey file:/etc/httpd/alias/session.key + Require valid-user + + Require all granted @@ -11,6 +24,7 @@ WSGIDaemonProcess ipa-tuura python-path=/www/ipa-tuura/src/ipa-tuura/root WSGIProcessGroup ipa-tuura WSGIScriptAlias / /www/ipa-tuura/src/ipa-tuura/root/wsgi.py + WSGIPassAuthorization On SSLEngine on SSLCertificateFile /etc/pki/tls/certs/apache-selfsigned.crt diff --git a/src/ipa-tuura/domains/adapters.py b/src/ipa-tuura/domains/adapters.py index fc55a90b..6c7b4e2d 100644 --- a/src/ipa-tuura/domains/adapters.py +++ b/src/ipa-tuura/domains/adapters.py @@ -25,4 +25,5 @@ class Meta: "user_object_classes", "users_dn", "ldap_tls_cacert", + "keycloak_hostname", ) diff --git a/src/ipa-tuura/domains/models.py b/src/ipa-tuura/domains/models.py index dba9fb84..6eb5b735 100644 --- a/src/ipa-tuura/domains/models.py +++ b/src/ipa-tuura/domains/models.py @@ -46,6 +46,9 @@ class DomainProviderType(models.TextChoices): # Temporary admin service password client_secret = models.CharField(max_length=20) + # External hostname for Keycloak host + keycloak_hostname = models.CharField(max_length=255, blank=True) + # Identity provider type id_provider = models.CharField( max_length=5, diff --git a/src/ipa-tuura/domains/utils.py b/src/ipa-tuura/domains/utils.py index a3bbb304..4af71b62 100644 --- a/src/ipa-tuura/domains/utils.py +++ b/src/ipa-tuura/domains/utils.py @@ -85,6 +85,22 @@ def activate_ifp(domain): sssdconfig.write() +def run_ssh_command(user, host, password, command): + target = "{}@{}".format(user, host) + cmd = [ + "sudo", + "sshpass", + "-p", + password, + "ssh", + target, + "-o", + "StrictHostKeyChecking=no", + command, + ] + return subprocess.run(cmd, capture_output=True) + + def install_client(domain): """ :param domain @@ -212,8 +228,10 @@ def deploy_ipa_service(domain): hostname = socket.gethostname() realm = domain["name"].upper() ipatuura_principal = "ipatuura/%s@%s" % (hostname, realm) + http_principal = "HTTP/%s@%s" % (domain["keycloak_hostname"], realm) keytab_file = os.environ.get("KRB5_CLIENT_KTNAME", None) keytab_path = os.path.dirname(keytab_file) + http_keytab_file = "/var/lib/ipatuura/httpd.keytab" ipa_api_connect(domain) @@ -254,6 +272,15 @@ def deploy_ipa_service(domain): else: logger.info(f"ipa: service_add result {result}") + # add HTTP service + try: + result = api.Command["service_add"](krbcanonicalname=http_principal) + except ipalib.errors.DuplicateEntry: + logger.info("service %s already exists", http_principal) + pass + else: + logger.info(f"ipa: service_add result {result}") + # add role try: result = api.Command["role_add"](cn="ipatuura writable interface") @@ -291,6 +318,12 @@ def deploy_ipa_service(domain): if proc.returncode != 0: raise Exception("Error getkeytab:\n{}".format(proc.stderr)) + # get keytab for HTTP service + args = ["ipa-getkeytab", "-p", http_principal, "-k", http_keytab_file] + proc = subprocess.run(args, capture_output=True, text=True) + if proc.returncode != 0: + raise Exception("Error getkeytab:\n{}".format(proc.stderr)) + def remove_sssd_domain(domain): try: @@ -374,6 +407,35 @@ def join_ad_realm(domain): # workaround until we have rootless SSSD subprocess.run(["sudo", "chmod", "660", "/etc/sssd/sssd.conf"]) + # Register user and SPN for HTTP, request keytab + ad_server = domainconfig.get_option("ad_server") + ad_realm = domainconfig.get_option("krb5_realm") + ad_passwd = domain["client_secret"] + kc_hostname = domain["keycloak_hostname"] + spn_commands = ( + "powershell -c '" + "New-ADUser ipatuura;" + "Enable-ADAccount -Identity ipatuura;" + "Set-ADUser ipatuura -KerberosEncryptionType AES256;" + f"setspn -S HTTP/{kc_hostname} ipatuura;" + "$kvno = Get-ADuser ipatuura -property msDS-KeyVersionNumber | select -expand msDS-KeyVersionNumber;" + f"ktpass -out /httpd.keytab -mapUser ipatuura@{ad_realm} -pass {ad_passwd} -mapOp set +DumpSalt -crypto AES256-SHA1 -ptype KRB5_NT_PRINCIPAL -princ HTTP/{kc_hostname}@{ad_realm} -kvno $kvno'" + ) + run_ssh_command("Administrator", ad_server, ad_passwd, spn_commands) + + # Fetch generated keytab + subprocess.run( + [ + "sudo", + "sshpass", + "-p", + ad_passwd, + "scp", + f"Administrator@{ad_server}:C:/httpd.keytab", + "/var/lib/ipatuura/httpd.keytab", + ] + ) + def config_default_sssd(domain): """ diff --git a/src/ipa-tuura/root/urls.py b/src/ipa-tuura/root/urls.py index 15e8b6b8..d726c248 100644 --- a/src/ipa-tuura/root/urls.py +++ b/src/ipa-tuura/root/urls.py @@ -25,4 +25,5 @@ path("scim/v2/", include("django_scim.urls")), path("creds/", include("creds.urls")), path("domains/v1/", include("domains.urls")), + path("bridge/", include("scim.urls")), ] diff --git a/src/ipa-tuura/scim/urls.py b/src/ipa-tuura/scim/urls.py new file mode 100644 index 00000000..659d3ec1 --- /dev/null +++ b/src/ipa-tuura/scim/urls.py @@ -0,0 +1,35 @@ +# +# Copyright (C) 2024 FreeIPA Contributors see COPYING for license +# + +"""Integration Domain URL Configuration + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/3.0/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" +import logging + +from django.urls import include, re_path +from rest_framework.routers import DefaultRouter +from scim.views import BridgeViewSet + +logger = logging.getLogger(__name__) + + +router = DefaultRouter() +router.register("", BridgeViewSet, "bridge") + +urlpatterns = [ + # we only need /bridge/login_password + re_path("", include(router.urls[:1])), +] diff --git a/src/ipa-tuura/scim/utils.py b/src/ipa-tuura/scim/utils.py index 7c0b18a6..7085e715 100644 --- a/src/ipa-tuura/scim/utils.py +++ b/src/ipa-tuura/scim/utils.py @@ -1,9 +1,13 @@ # -# Copyright (C) 2022 FreeIPA Contributors see COPYING for license +# Copyright (C) 2024 FreeIPA Contributors see COPYING for license # +from base64 import b64decode, b64encode + +import gssapi from django.db import NotSupportedError from django_scim.filters import GroupFilterQuery, UserFilterQuery +from requests.auth import AuthBase from scim.models import SSSDGroupToGroupModel, SSSDUserToUserModel from scim.sssd import SSSD, SSSDNotFoundException @@ -82,3 +86,74 @@ def search(cls, filter_query, request=None): group = SSSDGroupToGroupModel(sssd_if, sssdgroup) return [group] + + +class NegotiateAuth(AuthBase): + """Negotiate Auth using python GSSAPI""" + + def __init__(self, target_host, ccache_name=None): + self.context = None + self.target_host = target_host + self.ccache_name = ccache_name + + def __call__(self, request): + self.initial_step(request) + request.register_hook("response", self.handle_response) + return request + + def deregister(self, response): + response.request.deregister_hook("response", self.handle_response) + + def _get_negotiate_token(self, response): + token = None + if response is not None: + h = response.headers.get("www-authenticate", "") + if h.startswith("Negotiate"): + val = h[h.find("Negotiate") + len("Negotiate") :].strip() + if len(val) > 0: + token = b64decode(val) + return token + + def _set_authz_header(self, request, token): + request.headers["Authorization"] = "Negotiate {}".format( + b64encode(token).decode("utf-8") + ) + + def initial_step(self, request, response=None): + if self.context is None: + store = {"ccache": self.ccache_name} + creds = gssapi.Credentials(usage="initiate", store=store) + name = gssapi.Name( + "HTTP@{0}".format(self.target_host), + name_type=gssapi.NameType.hostbased_service, + ) + self.context = gssapi.SecurityContext( + creds=creds, name=name, usage="initiate" + ) + + in_token = self._get_negotiate_token(response) + out_token = self.context.step(in_token) + self._set_authz_header(request, out_token) + + def handle_response(self, response, **kwargs): + status = response.status_code + if status >= 400 and status != 401: + return response + + in_token = self._get_negotiate_token(response) + if in_token is not None: + out_token = self.context.step(in_token) + if self.context.complete: + return response + elif not out_token: + return response + + self._set_authz_header(response.request, out_token) + # use response so we can make another request + _ = response.content # pylint: disable=unused-variable + response.raw.release_conn() + newresp = response.connection.send(response.request, **kwargs) + newresp.history.append(response) + return self.handle_response(newresp, **kwargs) + + return response diff --git a/src/ipa-tuura/scim/views.py b/src/ipa-tuura/scim/views.py index 255fd1a0..89f17e36 100644 --- a/src/ipa-tuura/scim/views.py +++ b/src/ipa-tuura/scim/views.py @@ -1,7 +1,68 @@ # -# Copyright (C) 2022 FreeIPA Contributors see COPYING for license +# Copyright (C) 2023 FreeIPA Contributors see COPYING for license # -# from django.shortcuts import render +from ipalib.facts import is_ipa_client_configured +from rest_framework import status +from rest_framework.decorators import action +from rest_framework.response import Response +from rest_framework.viewsets import GenericViewSet -# Create your views here. +try: + from ipalib.install.kinit import kinit_password +except ImportError: + from ipapython.ipautil import kinit_password + +import logging +import os +import tempfile + +import requests +import SSSDConfig +from scim.utils import NegotiateAuth + +logger = logging.getLogger(__name__) + + +class BridgeViewSet(GenericViewSet): + @action(detail=False, methods=["post"]) + def login_password(self, request): + if not is_ipa_client_configured(): + raise RuntimeError("IPA client is not configured.") + + if "user" not in request.POST: + return Response("User not specified", status=status.HTTP_400_BAD_REQUEST) + if "password" not in request.POST: + return Response( + "Password not specified", status=status.HTTP_400_BAD_REQUEST + ) + + try: + sssdconfig = SSSDConfig.SSSDConfig() + sssdconfig.import_config() + except Exception as e: + logger.info("Unable to read SSSD config") + raise e + + ccache_dir = tempfile.mkdtemp(prefix="krbcc") + ccache_name = os.path.join(ccache_dir, "ccache") + + user = request.POST["user"] + passwd = request.POST["password"] + + try: + kinit_password(user, passwd, ccache_name) + except RuntimeError as e: + raise RuntimeError("Kerberos authentication failed: {}".format(e)) + + # We now have a valid ticket, request a session cookie + domain = sssdconfig.get_domain(sssdconfig.list_active_domains()[0]) + server_hostname = domain.get_option("ipa_server").split(", ")[-1] + r = requests.get( + "https://{0}/ipa/session/login_kerberos".format(server_hostname), + auth=NegotiateAuth(server_hostname, ccache_name), + verify="/etc/ipa/ca.crt", + ) + session_cookie = r.cookies.get("ipa_session") + + return Response({"session": session_cookie})