diff --git a/dd-ctl b/dd-ctl index 8557fda..1412e2b 100755 --- a/dd-ctl +++ b/dd-ctl @@ -476,11 +476,9 @@ saml_certificates(){ echo " --> Setting up SAML for wordpress" docker exec -ti dd-sso-admin sh -c "export PYTHONWARNINGS='ignore:Unverified HTTPS request' && cd /admin/saml_scripts/ && python3 wordpress_saml.py" - # SAML PLUGIN MOODLE - # echo "To add SAML to moodle:" - # echo "1.-Activate SAML plugin in moodle extensions, regenerate certificate, lock certificate" - # echo "2.-Then run: docker exec -ti dd-sso-admin python3 /admin/nextcloud_saml.py" - # echo "3.-" + # SAML PLUGIN EMAIL + echo " --> Setting up SAML for email" + docker exec -ti dd-sso-admin sh -c "export PYTHONWARNINGS='ignore:Unverified HTTPS request' && cd /admin/saml_scripts/ && python3 email_saml.py" } wait_for_moodle(){ diff --git a/dd-sso/admin/src/saml_scripts/email_saml.py b/dd-sso/admin/src/saml_scripts/email_saml.py new file mode 100644 index 0000000..9e7e4dc --- /dev/null +++ b/dd-sso/admin/src/saml_scripts/email_saml.py @@ -0,0 +1,187 @@ +# +# Copyright © 2022 Evilham +# +# This file is part of DD +# +# DD is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or (at your +# option) any later version. +# +# DD is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License +# along with DD. If not, see . +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +import logging as log +import os +import time +import traceback +from typing import Any, Dict, Optional + +from lib.keycloak_client import KeycloakClient + +try: + # Python 3.9+ + from functools import cache # type: ignore # Currently targetting 3.8 +except ImportError: + # Up to python 3.8 + from functools import cached_property as cache + + +app: Dict[str, Dict[str, str]] = {} +app["config"] = {} + + +class SamlService(object): + """ + Generic class to manage a SAML service on keycloak + """ + + keycloak: KeycloakClient + domain: str = os.environ["DOMAIN"] + + def __init__(self): + self.keycloak = KeycloakClient() + + @cache + def public_cert(self) -> str: + """ + Read the public SAML certificate as used by keycloak + """ + ready = False + basepath = os.path.dirname(__file__) + while not ready: + # TODO: Check why they were using a loop + try: + with open( + os.path.abspath( + os.path.join(basepath, "../saml_certs/public.cert") + ), + "r", + ) as crt: + app["config"]["PUBLIC_CERT"] = crt.read() + ready = True + except IOError: + log.warning("Could not get public certificate for SAML. Retrying...") + log.warning( + " You should generate them: /admin/saml_certs # openssl req -nodes -new -x509 -keyout private.key -out public.cert" + ) + time.sleep(2) + except: + log.error(traceback.format_exc()) + log.info("Got public SAML certificate") + return app["config"]["PUBLIC_CERT"] + + def get_client(self, client_id: str) -> Any: + # TODO: merge with keycloak_config.py + self.keycloak.connect() + k = self.keycloak.keycloak_admin + + clients = k.get_clients() + client = next(filter(lambda c: c["clientId"] == client_id, clients), None) + return (k, client) + + def set_client(self, client_id: str, client_overrides: Dict[str, Any]) -> str: + (k, client) = self.get_client(client_id) + if client is None: + client_uid = k.create_client(client_overrides) + else: + client_uid = client["id"] + k.update_client(client_uid, client_overrides) + return client_id + + def configure(self) -> None: + pass + + +class EmailSaml(SamlService): + client_name: str = "correu" + client_description: str = "Client for the DD-managed email service" + email_domain: str + + def __init__(self, email_domain: str, enabled: bool = False): + super(EmailSaml, self).__init__() + self.email_domain = email_domain + + @property + def enabled(self) -> bool: + return bool(self.email_domain) + + def configure(self) -> None: + srv_base = f"https://correu.{self.domain}" + client_id = f"{srv_base}/metadata/" + client_overrides: Dict[str, Any] = { + "name": self.client_name, + "description": self.client_description, + "clientId": client_id, + "baseUrl": f"{srv_base}/login", + "enabled": self.enabled, + "redirectUris": [ + f"{srv_base}/*", + ], + "webOrigins": [srv_base], + "consentRequired": False, + "protocol": "saml", + "attributes": { + "saml.assertion.signature": True, + "saml_idp_initiated_sso_relay_state": f"{srv_base}/login", + "saml_assertion_consumer_url_redirect": f"{srv_base}/acs", + "saml.force.post.binding": True, + "saml.multivalued.roles": False, + "saml.encrypt": False, + "saml_assertion_consumer_url_post": f"{srv_base}/acs", + "saml.server.signature": True, + "saml_idp_initiated_sso_url_name": f"{srv_base}/acs", + "saml.server.signature.keyinfo.ext": False, + "exclude.session.state.from.auth.response": False, + "saml_single_logout_service_url_redirect": f"{srv_base}/ls", + "saml.signature.algorithm": "RSA_SHA256", + "saml_force_name_id_format": False, + "saml.client.signature": False, + "tls.client.certificate.bound.access.tokens": False, + "saml.authnstatement": True, + "display.on.consent.screen": False, + "saml_name_id_format": "username", + "saml_signature_canonicalization_method": "http://www.w3.org/2001/10/xml-exc-c14n#", + "saml.onetimeuse.condition": False, + }, + "protocolMappers": [ + { + "name": "username", + "protocol": "saml", + "protocolMapper": "saml-user-property-mapper", + "consentRequired": False, + "config": { + "attribute.nameformat": "Basic", + "user.attribute": "username", + "friendly.name": "username", + "attribute.name": "username", + }, + }, + { + "name": "email", + "protocol": "saml", + "protocolMapper": "saml-user-property-mapper", + "consentRequired": False, + "config": { + "attribute.nameformat": "Basic", + "user.attribute": "email", + "friendly.name": "email", + "attribute.name": "email", + }, + }, + ], + } + self.set_client(client_id, client_overrides) + + +if __name__ == "__main__": + email_domain = os.environ.get("MANAGED_EMAIL_DOMAIN", "") + log.info("Configuring SAML client for Email") + EmailSaml(email_domain).configure() diff --git a/dd-sso/docker-compose-parts/admin.yml b/dd-sso/docker-compose-parts/admin.yml index 46c7afb..8f65a3b 100644 --- a/dd-sso/docker-compose-parts/admin.yml +++ b/dd-sso/docker-compose-parts/admin.yml @@ -48,3 +48,4 @@ services: environment: - VERIFY="false" # In development do not verify certificates - DOMAIN=${DOMAIN} + - MANAGED_EMAIL_DOMAIN=${MANAGED_EMAIL_DOMAIN} diff --git a/dd.conf.sample b/dd.conf.sample index 0b924c7..132534f 100644 --- a/dd.conf.sample +++ b/dd.conf.sample @@ -22,6 +22,8 @@ TITLE="DD" TITLE_SHORT="DD" DOMAIN=mydomain.com +# If defined, DD will be managing email for this domain +#MANAGED_EMAIL_DOMAIN=${DOMAIN} LETSENCRYPT_DNS= LETSENCRYPT_EMAIL= # Generate letsencrypt certificate for root domain