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})