2019-08-20 12:00:54 +02:00
|
|
|
import json
|
|
|
|
import logging
|
|
|
|
import os
|
2021-04-19 11:36:43 +02:00
|
|
|
import re
|
2019-08-20 12:00:54 +02:00
|
|
|
from typing import Dict, Tuple
|
|
|
|
|
|
|
|
from django.conf import settings
|
|
|
|
from onelogin.saml2.settings import OneLogin_Saml2_Settings
|
|
|
|
|
|
|
|
from .exceptions import SamlException
|
|
|
|
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
|
|
README = """\
|
|
|
|
Take care of this folder that could contain private key. Be sure that this folder never is published.
|
|
|
|
OpenSlides SAML plugin expects that certs for the SP could be stored in this folder as:
|
|
|
|
* sp.key Private Key
|
|
|
|
* sp.crt Public cert
|
|
|
|
* sp_new.crt Future Public cert
|
|
|
|
Also you can use other cert to sign the metadata of the SP using the:
|
|
|
|
* metadata.key
|
|
|
|
* metadata.crt"""
|
|
|
|
|
|
|
|
|
|
|
|
def get_settings_dir_and_path() -> Tuple[str, str]:
|
|
|
|
"""
|
|
|
|
Returns the settings directory and as the seconds return value
|
|
|
|
the path to the saml settings file.
|
|
|
|
"""
|
|
|
|
try:
|
|
|
|
settings_dir = os.path.dirname(os.path.abspath(settings.SETTINGS_FILEPATH))
|
|
|
|
except AttributeError:
|
|
|
|
raise SamlException(
|
|
|
|
"'SETTINGS_FILEPATH' is not in your settings.py. "
|
|
|
|
+ "Would you kindly add the following line: 'SETTINGS_FILEPATH = __file__'?"
|
|
|
|
)
|
|
|
|
settings_path = os.path.join(settings_dir, "saml_settings.json")
|
|
|
|
return settings_dir, settings_path
|
|
|
|
|
|
|
|
|
|
|
|
def create_saml_settings(
|
|
|
|
settings_path: str = None, template: str = None, **context: str
|
|
|
|
) -> None:
|
|
|
|
"""
|
|
|
|
Creates the SAML settings file 'saml_settings.json'
|
|
|
|
|
|
|
|
if the path is given, the settings will be written! If not, it is checked, if the
|
|
|
|
settings do exists.
|
|
|
|
"""
|
|
|
|
# If settings_path is none, do not force writing the file.
|
|
|
|
if settings_path is None:
|
|
|
|
# Check, if the file exists and exit then.
|
|
|
|
_, settings_path = get_settings_dir_and_path()
|
|
|
|
if os.path.isfile(settings_path):
|
|
|
|
return # it exist.
|
|
|
|
|
|
|
|
# OK, write the file.
|
|
|
|
settings_path = os.path.realpath(settings_path)
|
|
|
|
if template is None:
|
|
|
|
with open(
|
|
|
|
os.path.join(os.path.dirname(__file__), "saml_settings.json.tpl")
|
|
|
|
) as template_file:
|
|
|
|
template = template_file.read()
|
|
|
|
content = template % context
|
|
|
|
with open(settings_path, "w") as settings_file:
|
|
|
|
settings_file.write(content)
|
|
|
|
|
|
|
|
# create cert folder and add thr README
|
|
|
|
cert_dir = os.path.join(os.path.dirname(settings_path), "certs")
|
|
|
|
os.makedirs(cert_dir, exist_ok=True)
|
|
|
|
|
|
|
|
# create README there
|
|
|
|
readme_path = os.path.join(cert_dir, "README")
|
|
|
|
if not os.path.isfile(readme_path):
|
|
|
|
with open(readme_path, "w") as readme:
|
|
|
|
readme.write(README)
|
|
|
|
logger.info(f"Written README into the certs folder: {cert_dir}")
|
|
|
|
logger.info(f"Created SAML settings at: {settings_path}")
|
|
|
|
|
|
|
|
|
|
|
|
class SamlSettings:
|
|
|
|
"""
|
|
|
|
Holds all custom settings and saml settings from the saml_settings.json
|
|
|
|
|
|
|
|
Custom Settings:
|
|
|
|
- general_settings: {
|
|
|
|
loginButtonText: <str>,
|
|
|
|
changePasswordUrl: <str>
|
|
|
|
}
|
|
|
|
- attribute_mapping: {
|
|
|
|
<idp_attr>: [<OS_attr>, <lookup>]
|
|
|
|
}
|
|
|
|
- request_settings: {
|
|
|
|
<key>: <value>,
|
|
|
|
}
|
2021-04-19 11:36:43 +02:00
|
|
|
- groups: {
|
|
|
|
matchers: [{
|
|
|
|
regex: <regex>,
|
|
|
|
attribute: <str>,
|
|
|
|
group_ids: [<id>, ...]
|
|
|
|
}],
|
|
|
|
default_group_ids: [<id>, ...] | null | undefined
|
|
|
|
}
|
2019-08-20 12:00:54 +02:00
|
|
|
"""
|
|
|
|
|
|
|
|
def __init__(self):
|
|
|
|
create_saml_settings()
|
|
|
|
self.load_settings()
|
|
|
|
|
|
|
|
def load_settings(self):
|
|
|
|
# Try to open the settings file.
|
|
|
|
content = None
|
|
|
|
settings_dir, settings_path = get_settings_dir_and_path()
|
|
|
|
try:
|
|
|
|
with open(settings_path, "r") as settings_file:
|
|
|
|
content = json.load(settings_file)
|
|
|
|
except IOError:
|
|
|
|
raise SamlException(
|
|
|
|
f"Could not read settings file located at: {settings_path}"
|
|
|
|
)
|
|
|
|
except json.JSONDecodeError:
|
|
|
|
raise SamlException(
|
|
|
|
f"The settings file located at {settings_path} could not be loaded."
|
|
|
|
)
|
|
|
|
logger.info(f"Loaded settings: {settings_path}")
|
|
|
|
|
|
|
|
# Extract special settings
|
|
|
|
self.load_general_settings(content)
|
|
|
|
self.load_attribute_mapping(content)
|
|
|
|
self.load_request_settings(content)
|
2021-04-19 11:36:43 +02:00
|
|
|
self.load_groups(content)
|
2019-08-20 12:00:54 +02:00
|
|
|
|
|
|
|
# Load saml settings
|
|
|
|
self.saml_settings = OneLogin_Saml2_Settings(
|
|
|
|
content, custom_base_path=settings_dir
|
|
|
|
)
|
|
|
|
|
|
|
|
def load_general_settings(self, content):
|
|
|
|
if "generalSettings" not in content:
|
|
|
|
raise SamlException(
|
|
|
|
"The saml_settings.json does not contain 'generalSettings'!"
|
|
|
|
)
|
|
|
|
self.general_settings = content.pop("generalSettings")
|
|
|
|
|
|
|
|
if not isinstance(self.general_settings, dict):
|
|
|
|
raise SamlException("The generalSettings have to be a dict.")
|
|
|
|
if "loginButtonText" not in self.general_settings:
|
|
|
|
raise SamlException("The loginButtonText is not given.")
|
|
|
|
if not isinstance(self.general_settings["loginButtonText"], str):
|
|
|
|
raise SamlException("The loginButtonText has to be a string.")
|
|
|
|
if "changePasswordUrl" not in self.general_settings:
|
|
|
|
raise SamlException("The changePasswordUrl is not given.")
|
|
|
|
if not isinstance(self.general_settings["changePasswordUrl"], str):
|
|
|
|
raise SamlException("The changePasswordUrl has to be a string.")
|
|
|
|
|
|
|
|
def load_attribute_mapping(self, content):
|
|
|
|
if "attributeMapping" not in content:
|
|
|
|
raise SamlException(
|
|
|
|
"The saml_settings.json does not contain 'attributeMapping'!"
|
|
|
|
)
|
|
|
|
self.attribute_mapping = content.pop("attributeMapping")
|
|
|
|
|
|
|
|
allowed_attributes = [
|
|
|
|
"username",
|
|
|
|
"first_name",
|
|
|
|
"last_name",
|
|
|
|
"gender",
|
|
|
|
"email",
|
|
|
|
"title",
|
|
|
|
"structure_level",
|
|
|
|
"number",
|
|
|
|
"comment",
|
|
|
|
"is_active",
|
|
|
|
"is_present",
|
|
|
|
"is_committee",
|
|
|
|
]
|
|
|
|
|
|
|
|
one_lookup_true = False
|
|
|
|
if not isinstance(self.attribute_mapping, dict):
|
|
|
|
raise SamlException("The attributeMapping is not a dict.")
|
|
|
|
for key, value in self.attribute_mapping.items():
|
|
|
|
if not isinstance(key, str):
|
|
|
|
raise SamlException(f'The key "{key}" has to be a string.')
|
|
|
|
if not isinstance(value, list):
|
|
|
|
raise SamlException(f'The value from key "{key}" has to be a list.')
|
|
|
|
if not len(value) == 2:
|
|
|
|
raise SamlException(f'The value from key "{key}" has ot two entries.')
|
|
|
|
os_attribute, lookup = value
|
|
|
|
if not isinstance(os_attribute, str):
|
|
|
|
raise SamlException(
|
|
|
|
f'The first value from key "{key}" has to be a string.'
|
|
|
|
)
|
|
|
|
if os_attribute not in allowed_attributes:
|
|
|
|
all_attrs = ", ".join(allowed_attributes)
|
|
|
|
raise SamlException(
|
|
|
|
f"The attribute {os_attribute} is not allowed. All allowed attributes: {all_attrs}"
|
|
|
|
)
|
|
|
|
if not isinstance(value[1], bool):
|
|
|
|
raise SamlException(
|
|
|
|
f'The lookup value from key "{key}" has to be a boolean.'
|
|
|
|
)
|
|
|
|
if value[1]:
|
|
|
|
one_lookup_true = True
|
|
|
|
|
|
|
|
if not one_lookup_true:
|
|
|
|
raise SamlException(
|
|
|
|
"At least one attribute has to be used as a lookup value."
|
|
|
|
)
|
|
|
|
|
|
|
|
def load_request_settings(self, content):
|
|
|
|
self.request_settings: Dict[str, str] = {}
|
|
|
|
if "requestSettings" in content:
|
|
|
|
self.request_settings = content.pop("requestSettings")
|
|
|
|
|
|
|
|
if not isinstance(self.request_settings, dict):
|
|
|
|
raise SamlException("The requestSettings have to be a dict")
|
|
|
|
if "https" in self.request_settings and self.request_settings[
|
|
|
|
"https"
|
|
|
|
] not in ("on", "off"):
|
|
|
|
raise SamlException('The https value must be "on" or "off"')
|
|
|
|
|
2021-04-19 11:36:43 +02:00
|
|
|
def load_groups(self, content):
|
|
|
|
self.groups = content.pop("groups", {})
|
|
|
|
matchers = self.groups.get("matchers", [])
|
|
|
|
for i, m in enumerate(matchers):
|
|
|
|
if not isinstance(m.get("attribute"), str):
|
|
|
|
raise SamlException(f"Attribute of matcher {i+1} must be a string")
|
|
|
|
regex = m["regex"]
|
|
|
|
if not regex:
|
|
|
|
raise SamlException(f"Matcher {i+1} does not have a regex")
|
|
|
|
try:
|
|
|
|
regex = re.compile(regex)
|
|
|
|
except re.error as e:
|
|
|
|
raise SamlException(f"The regex for matcher {i+1} is invalid: {str(e)}")
|
|
|
|
m["regex"] = regex
|
|
|
|
m["group_ids"] = m.get("group_ids", [])
|
|
|
|
if not m["group_ids"]:
|
|
|
|
raise SamlException(f"The group_ids for matcher {i+1} is empty")
|
|
|
|
self.assert_list_of_ints(m["group_ids"], f"group_ids of matcher {i+1}")
|
|
|
|
self.groups["matchers"] = matchers
|
|
|
|
|
|
|
|
default_group_ids = self.groups.pop("default_group_ids", [])
|
|
|
|
self.assert_list_of_ints(default_group_ids, "default_group_ids")
|
|
|
|
self.groups["default_group_ids"] = default_group_ids
|
|
|
|
|
|
|
|
logger.info(f"Loaded {len(matchers)} matchers")
|
|
|
|
logger.info(f"Configured {default_group_ids} as default groups")
|
|
|
|
|
|
|
|
def assert_list_of_ints(self, value, name):
|
|
|
|
if not isinstance(value, list):
|
|
|
|
raise SamlException(f"{name} must be a list of integers")
|
|
|
|
for x in value:
|
|
|
|
if not isinstance(x, int):
|
|
|
|
raise SamlException(f"Each entry of {name} must be an integer")
|
2020-06-12 14:37:43 +02:00
|
|
|
|
2019-08-20 12:00:54 +02:00
|
|
|
|
|
|
|
saml_settings = None
|
|
|
|
|
|
|
|
|
|
|
|
def get_saml_settings():
|
|
|
|
global saml_settings
|
|
|
|
return saml_settings
|
|
|
|
|
|
|
|
|
|
|
|
def load_settings():
|
|
|
|
global saml_settings
|
|
|
|
saml_settings = SamlSettings()
|