# # 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()