From 48ed541d677226fbf4445328a3a2514c36472ef1 Mon Sep 17 00:00:00 2001 From: dezhidki Date: Fri, 4 Nov 2022 16:04:43 +0200 Subject: [PATCH] saml: Refactor to use PySAML2 OneLogin's implementation is deprecated and not actively maintained at the moment (https://github.com/onelogin/python3-saml/issues/320) --- poetry.lock | 71 +--- pyproject.toml | 4 +- timApp/auth/saml.py | 563 ------------------------- timApp/auth/saml/__init__.py | 0 timApp/auth/saml/attributemaps/haka.py | 16 + timApp/auth/saml/attributes.py | 108 +++++ timApp/auth/saml/client.py | 111 +++++ timApp/auth/saml/dev/config.py | 36 ++ timApp/auth/saml/dev/settings.json | 57 --- timApp/auth/saml/identity_assurance.py | 55 +++ timApp/auth/saml/prod/readme.md | 4 +- timApp/auth/saml/routes.py | 220 ++++++++++ timApp/auth/saml/test/config.py | 36 ++ timApp/auth/saml/test/settings.json | 57 --- timApp/defaultconfig.py | 11 +- timApp/tim.py | 2 +- timApp/util/flask/requesthelper.py | 9 +- 17 files changed, 609 insertions(+), 751 deletions(-) delete mode 100644 timApp/auth/saml.py create mode 100644 timApp/auth/saml/__init__.py create mode 100644 timApp/auth/saml/attributemaps/haka.py create mode 100644 timApp/auth/saml/attributes.py create mode 100644 timApp/auth/saml/client.py create mode 100644 timApp/auth/saml/dev/config.py delete mode 100644 timApp/auth/saml/dev/settings.json create mode 100644 timApp/auth/saml/identity_assurance.py create mode 100644 timApp/auth/saml/routes.py create mode 100644 timApp/auth/saml/test/config.py delete mode 100644 timApp/auth/saml/test/settings.json diff --git a/poetry.lock b/poetry.lock index ae2488959d..5958da4bae 100644 --- a/poetry.lock +++ b/poetry.lock @@ -381,7 +381,7 @@ dev = ["Sphinx", "coverage", "flake8", "lxml", "lxml-stubs", "memory-profiler", [[package]] name = "exceptiongroup" -version = "1.0.0" +version = "1.0.1" description = "Backport of PEP 654 (exception groups)" category = "main" optional = false @@ -971,15 +971,15 @@ tests = ["check-manifest", "coverage", "defusedxml", "markdown2", "olefile", "pa [[package]] name = "platformdirs" -version = "2.5.2" -description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." +version = "2.5.3" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." category = "dev" optional = false python-versions = ">=3.7" [package.extras] -docs = ["furo (>=2021.7.5b38)", "proselint (>=0.10.2)", "sphinx (>=4)", "sphinx-autodoc-typehints (>=1.12)"] -test = ["appdirs (==1.4.4)", "pytest (>=6)", "pytest-cov (>=2.7)", "pytest-mock (>=3.6)"] +docs = ["furo (>=2022.9.29)", "proselint (>=0.13)", "sphinx (>=5.3)", "sphinx-autodoc-typehints (>=1.19.4)"] +test = ["appdirs (==1.4.4)", "pytest (>=7.2)", "pytest-cov (>=4)", "pytest-mock (>=3.10)"] [[package]] name = "psycogreen" @@ -1180,22 +1180,6 @@ defusedxml = "*" mysql = ["mysql-connector-python"] postgresql = ["psycopg2"] -[[package]] -name = "python3-saml" -version = "1.14.0" -description = "Onelogin Python Toolkit. Add SAML support to your Python software using this library" -category = "main" -optional = false -python-versions = "*" - -[package.dependencies] -isodate = ">=0.6.1" -lxml = "<4.7.1" -xmlsec = ">=1.3.9" - -[package.extras] -test = ["coverage (>=4.5.2)", "flake8 (>=3.6.0)", "freezegun (>=0.3.11,<=1.1.0)", "pylint (==1.9.4)", "pytest (>=4.6)"] - [[package]] name = "pytz" version = "2022.6" @@ -1288,7 +1272,7 @@ pyasn1 = ">=0.1.3" [[package]] name = "selenium" -version = "4.5.0" +version = "4.6.0" description = "" category = "main" optional = false @@ -1829,17 +1813,6 @@ codegen = ["elementpath (>=3.0.0,<4.0.0)", "jinja2"] dev = ["Sphinx", "coverage", "elementpath (>=3.0.0,<4.0.0)", "flake8", "jinja2", "lxml", "lxml-stubs", "memory-profiler", "mypy", "sphinx-rtd-theme", "tox"] docs = ["Sphinx", "elementpath (>=3.0.0,<4.0.0)", "jinja2", "sphinx-rtd-theme"] -[[package]] -name = "xmlsec" -version = "1.3.13" -description = "Python bindings for the XML Security Library" -category = "main" -optional = false -python-versions = ">=3.5" - -[package.dependencies] -lxml = ">=3.8" - [[package]] name = "zope-event" version = "4.5.0" @@ -1874,7 +1847,7 @@ testing = ["coverage (>=5.0.3)", "zope.event", "zope.testing"] [metadata] lock-version = "1.1" python-versions = "^3.10" -content-hash = "6d91f855148ba53d492f34db528ba4b9e8ae3f0bebf1c74da63b9667292230f2" +content-hash = "3e1882fb09c1f2f91e20e0bfd6e64670161da43a56370abed8887bb80abfefa6" [metadata.files] alabaster = [ @@ -2172,8 +2145,8 @@ elementpath = [ {file = "elementpath-3.0.2.tar.gz", hash = "sha256:cca18742dc0f354f79874c41a906e6ce4cc15230b7858d22a861e1ec5946940f"}, ] exceptiongroup = [ - {file = "exceptiongroup-1.0.0-py3-none-any.whl", hash = "sha256:2ac84b496be68464a2da60da518af3785fff8b7ec0d090a581604bc870bdee41"}, - {file = "exceptiongroup-1.0.0.tar.gz", hash = "sha256:affbabf13fb6e98988c38d9c5650e701569fe3c1de3233cfb61c5f33774690ad"}, + {file = "exceptiongroup-1.0.1-py3-none-any.whl", hash = "sha256:4d6c0aa6dd825810941c792f53d7b8d71da26f5e5f84f20f9508e8f2d33b140a"}, + {file = "exceptiongroup-1.0.1.tar.gz", hash = "sha256:73866f7f842ede6cb1daa42c4af078e2035e5f7607f0e2c762cc51bb31bbe7b2"}, ] filelock = [ {file = "filelock-3.8.0-py3-none-any.whl", hash = "sha256:617eb4e5eedc82fc5f47b6d61e4d11cb837c56cb4544e39081099fa17ad109d4"}, @@ -2718,8 +2691,8 @@ pillow = [ {file = "Pillow-9.3.0.tar.gz", hash = "sha256:c935a22a557a560108d780f9a0fc426dd7459940dc54faa49d83249c8d3e760f"}, ] platformdirs = [ - {file = "platformdirs-2.5.2-py3-none-any.whl", hash = "sha256:027d8e83a2d7de06bbac4e5ef7e023c02b863d7ea5d079477e722bb41ab25788"}, - {file = "platformdirs-2.5.2.tar.gz", hash = "sha256:58c8abb07dcb441e6ee4b11d8df0ac856038f944ab98b7be6b27b2a3c7feef19"}, + {file = "platformdirs-2.5.3-py3-none-any.whl", hash = "sha256:0cb405749187a194f444c25c82ef7225232f11564721eabffc6ec70df83b11cb"}, + {file = "platformdirs-2.5.3.tar.gz", hash = "sha256:6e52c21afff35cb659c6e52d8b4d61b9bd544557180440538f255d9382c8cbe0"}, ] psycogreen = [ {file = "psycogreen-1.0.2.tar.gz", hash = "sha256:c429845a8a49cf2f76b71265008760bcd7c7c77d80b806db4dc81116dbcd130d"}, @@ -2859,11 +2832,6 @@ python3-openid = [ {file = "python3-openid-3.2.0.tar.gz", hash = "sha256:33fbf6928f401e0b790151ed2b5290b02545e8775f982485205a066f874aaeaf"}, {file = "python3_openid-3.2.0-py3-none-any.whl", hash = "sha256:6626f771e0417486701e0b4daff762e7212e820ca5b29fcc0d05f6f8736dfa6b"}, ] -python3-saml = [ - {file = "python3-saml-1.14.0.tar.gz", hash = "sha256:e8d04f06549b30e29f9f1d6787faf67558c19f7ed2f3cc0656abb169c8240bc9"}, - {file = "python3_saml-1.14.0-py2-none-any.whl", hash = "sha256:c18913fa1d92b0db01e2b15fd9a0c7e21805aa5b19492dc33969b8751826e888"}, - {file = "python3_saml-1.14.0-py3-none-any.whl", hash = "sha256:0e886f5f2d9caf93972a34de72ae2302f4c44bc71472a567cb234e2ab96d91e3"}, -] pytz = [ {file = "pytz-2022.6-py2.py3-none-any.whl", hash = "sha256:222439474e9c98fced559f1709d89e6c9cbf8d79c794ff3eb9f8800064291427"}, {file = "pytz-2022.6.tar.gz", hash = "sha256:e89512406b793ca39f5971bc999cc538ce125c0e51c27941bef4568b460095e2"}, @@ -2931,7 +2899,7 @@ rsa = [ {file = "rsa-4.9.tar.gz", hash = "sha256:e38464a49c6c85d7f1351b0126661487a7e0a14a50f1675ec50eb34d4f20ef21"}, ] selenium = [ - {file = "selenium-4.5.0-py3-none-any.whl", hash = "sha256:a733dd77d3171b846893f4d51b18967d809313f547a10974e26579f9ce797462"}, + {file = "selenium-4.6.0-py3-none-any.whl", hash = "sha256:3f1999875ef487ae676a254e7293a68041f1f1ec76be81402d8a1cd5a481bf3b"}, ] setuptools = [ {file = "setuptools-62.6.0-py3-none-any.whl", hash = "sha256:c1848f654aea2e3526d17fc3ce6aeaa5e7e24e66e645b5be2171f3f6b4e5a178"}, @@ -3267,21 +3235,6 @@ xmlschema = [ {file = "xmlschema-2.1.1-py3-none-any.whl", hash = "sha256:5717a8a239637a9ad7d7563ce676dddf0a8989644c833f96bfc6d157c3cb3750"}, {file = "xmlschema-2.1.1.tar.gz", hash = "sha256:5ca34ff15dd3276cfb2e3e7b4c8dde4b7d4d27080f333a93b6c3f817e90abddf"}, ] -xmlsec = [ - {file = "xmlsec-1.3.13-cp310-cp310-win32.whl", hash = "sha256:2174e8c88555383322d8b7d3927490a92ef72ad72a6ddaf4fa1b96a3f27c3e90"}, - {file = "xmlsec-1.3.13-cp310-cp310-win_amd64.whl", hash = "sha256:46d1daf16a8f4430efca5bb9c6a15776f2671f69f48a1941d6bb335e6f8cb29d"}, - {file = "xmlsec-1.3.13-cp35-cp35m-win32.whl", hash = "sha256:d47062c42775a025aa94fb8b15de97c1db86e301e549d3168157e0b1223d51b1"}, - {file = "xmlsec-1.3.13-cp35-cp35m-win_amd64.whl", hash = "sha256:7c7e8ef52688ddaf5b66750cc8d901f61716f46727014ff012f41d8858cedeb0"}, - {file = "xmlsec-1.3.13-cp36-cp36m-win32.whl", hash = "sha256:1725d70ee2bb2cd8dd66c7a7451be02bb59dc8280103db4f68e731f00135b1e0"}, - {file = "xmlsec-1.3.13-cp36-cp36m-win_amd64.whl", hash = "sha256:1f8c41162152d7086fd459926e61bc7cb2d52ffc829e760bf8b2c221a645d568"}, - {file = "xmlsec-1.3.13-cp37-cp37m-win32.whl", hash = "sha256:ff1c61f296e75cba5bac802d0000bfde09143eed946ced1a5162211867c335f8"}, - {file = "xmlsec-1.3.13-cp37-cp37m-win_amd64.whl", hash = "sha256:d249c0a2bf3ff13a231bca6a588e7d276b3f1e2cf09316b542f470a63855799e"}, - {file = "xmlsec-1.3.13-cp38-cp38-win32.whl", hash = "sha256:56cfcf3487b6ad269eb1fb543c04dee2c101f1bc91e06d6cf7bfab9ac486efd8"}, - {file = "xmlsec-1.3.13-cp38-cp38-win_amd64.whl", hash = "sha256:e6626bece0e97a8598b5df28c27bc6f2ae1e97d29dca3c1a4910a7598a4d1d0f"}, - {file = "xmlsec-1.3.13-cp39-cp39-win32.whl", hash = "sha256:091f23765729df6f3b3a55c8a6a96f9c713fa86e76b86a19cdb756aaa6dc0646"}, - {file = "xmlsec-1.3.13-cp39-cp39-win_amd64.whl", hash = "sha256:5162f416179350587c4ff64737af68a846a9b86f95fd465df4e68b589ce56618"}, - {file = "xmlsec-1.3.13.tar.gz", hash = "sha256:916f5d78e8041f6cd9391abba659da8c94a4fef7196d126d40af1ff417f2cf86"}, -] zope-event = [ {file = "zope.event-4.5.0-py2.py3-none-any.whl", hash = "sha256:2666401939cdaa5f4e0c08cf7f20c9b21423b95e88f4675b1443973bdb080c42"}, {file = "zope.event-4.5.0.tar.gz", hash = "sha256:5e76517f5b9b119acf37ca8819781db6c16ea433f7e2062c4afc2b6fbedb1330"}, diff --git a/pyproject.toml b/pyproject.toml index d88ed21b9c..8d46eb6f18 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,7 +57,6 @@ PyLaTeX = "^1.4.1" pypandoc = "^1.8.1" python-dateutil = "^2.8.2" python-magic = "^0.4.26" -python3-saml = "^1.14.0" pytz = "^2022.1" recommonmark = "^0.7.1" responses = "^0.21.0" @@ -165,6 +164,8 @@ module = [ "celery.schedules", "lxml", "lxml.*", + "saml2", + "saml2.*", "sqlalchemy", "sqlalchemy.dialects", "sqlalchemy.exc", @@ -193,7 +194,6 @@ module = [ "timApp.answer.routes", "timApp.auth.accesshelper", "timApp.auth.auth_models", - "timApp.auth.saml", "timApp.auth.sessioninfo", "timApp.defaultconfig", "timApp.document.attributeparser", diff --git a/timApp/auth/saml.py b/timApp/auth/saml.py deleted file mode 100644 index 844cbf3946..0000000000 --- a/timApp/auth/saml.py +++ /dev/null @@ -1,563 +0,0 @@ -import functools -import json -import os -from copy import copy -from dataclasses import dataclass, field -from enum import Enum -from functools import cached_property, total_ordering -from typing import Optional, Any -from urllib.parse import urlparse - -import xmlsec -from flask import request, redirect, session, make_response, Blueprint, Request, url_for -from lxml import etree -from lxml.cssselect import CSSSelector -from onelogin.saml2.auth import OneLogin_Saml2_Auth -from onelogin.saml2.errors import OneLogin_Saml2_Error, OneLogin_Saml2_ValidationError -from onelogin.saml2.idp_metadata_parser import OneLogin_Saml2_IdPMetadataParser -from onelogin.saml2.settings import OneLogin_Saml2_Settings -from onelogin.saml2.utils import OneLogin_Saml2_Utils, return_false_on_exception -from onelogin.saml2.xml_utils import OneLogin_Saml2_XML - -from timApp.auth.accesshelper import AccessDenied -from timApp.auth.login import create_or_update_user, set_user_to_session -from timApp.auth.sessioninfo import logged_in -from timApp.tim_app import app, csrf -from timApp.timdb.sqa import db -from timApp.user.personaluniquecode import SchacPersonalUniqueCode -from timApp.user.user import UserInfo, UserOrigin -from timApp.user.usercontact import ContactOrigin -from timApp.user.usergroup import UserGroup -from timApp.util.flask.cache import cache -from timApp.util.flask.requesthelper import use_model, RouteException -from timApp.util.flask.responsehelper import json_response -from timApp.util.logger import log_warning - -saml = Blueprint("saml", __name__, url_prefix="/saml") - - -class FingerPrintException(Exception): - pass - - -def load_sp_settings( - hostname=None, try_new_cert=False, sp_validation_only=False -) -> tuple[str, OneLogin_Saml2_Settings]: - """ - Loads OneLogin Saml2 settings for the given hostname. - - Behaves like OneLogin_Saml2_Settings constructor with custom_base_path set, but allows to dynamically change - Assertion Consumer Service URL. If the ACS URL has $hostname variable, it's replaced with the given hostname - argument. - - .. note: Templating is used because OneLogin does not support multiple ACSs at the moment, see - https://github.com/onelogin/python-saml/issues/207 - - :param hostname: Hostname for which to generate the ACS callback URL. If None, TIM_HOST is used. - :param try_new_cert: If True, settings and certificate from /new folder is used. - :param sp_validation_only: If True, the SP settings are only validated. - :return: Tuple (str, OneLogin_Saml2_Settings) contains path to current Saml2 settings and generated SP settings - object - """ - saml_path = app.config["SAML_PATH"] - if try_new_cert: - saml_path += "/new" - - filename = os.path.join(saml_path, "settings.json") - try: - with open(filename) as json_data: - settings = json.loads(json_data.read()) - except FileNotFoundError: - raise OneLogin_Saml2_Error( - "Settings file not found: %s", - OneLogin_Saml2_Error.SETTINGS_FILE_NOT_FOUND, - filename, - ) - - try: - advanced_filename = os.path.join(saml_path, "advanced_settings.json") - with open(advanced_filename) as json_data: - settings.update(json.loads(json_data.read())) - except FileNotFoundError: - pass - - acs_info = settings["sp"]["assertionConsumerService"] - acs_info["url"] = acs_info["url"].replace( - "$hostname", hostname or app.config["TIM_HOST"] - ) - - ol_settings = OneLogin_Saml2_Settings( - settings=settings, - custom_base_path=saml_path, - sp_validation_only=sp_validation_only, - ) - return saml_path, ol_settings - - -@return_false_on_exception -def validate_node_sign( - signature_node, - elem, - cert=None, - fingerprint=None, - fingerprintalg="sha1", - validatecert=False, - debug=False, -): - """ - Same as OneLogin_Saml2_Utils.validate_node_sign but with the following changes: - - * If the certificate fingerprint does not match, an exception is raised (to make debugging easier). - """ - if (cert is None or cert == "") and fingerprint: - x509_certificate_nodes = OneLogin_Saml2_XML.query( - signature_node, "//ds:Signature/ds:KeyInfo/ds:X509Data/ds:X509Certificate" - ) - if len(x509_certificate_nodes) > 0: - x509_certificate_node = x509_certificate_nodes[0] - x509_cert_value = OneLogin_Saml2_XML.element_text(x509_certificate_node) - x509_cert_value_formatted = OneLogin_Saml2_Utils.format_cert( - x509_cert_value - ) - x509_fingerprint_value = OneLogin_Saml2_Utils.calculate_x509_fingerprint( - x509_cert_value_formatted, fingerprintalg - ) - if fingerprint == x509_fingerprint_value: - cert = x509_cert_value_formatted - else: - raise FingerPrintException( - f"Expected certificate fingerprint {fingerprint} but got {x509_fingerprint_value}" - ) - - if cert is None or cert == "": - raise OneLogin_Saml2_Error( - "Could not validate node signature: No certificate provided.", - OneLogin_Saml2_Error.CERT_NOT_FOUND, - ) - - if validatecert: - manager = xmlsec.KeysManager() - manager.load_cert_from_memory( - cert, xmlsec.KeyFormat.CERT_PEM, xmlsec.KeyDataType.TRUSTED - ) - dsig_ctx = xmlsec.SignatureContext(manager) - else: - dsig_ctx = xmlsec.SignatureContext() - dsig_ctx.key = xmlsec.Key.from_memory(cert, xmlsec.KeyFormat.CERT_PEM, None) - - dsig_ctx.set_enabled_key_data([xmlsec.KeyData.X509]) - - try: - dsig_ctx.verify(signature_node) - except Exception as err: - raise OneLogin_Saml2_ValidationError( - "Signature validation failed. %s", - OneLogin_Saml2_ValidationError.INVALID_SIGNATURE, - str(err), - ) - - return True - - -OneLogin_Saml2_Utils.validate_node_sign = validate_node_sign - - -def do_validate_metadata(idp_metadata_xml: str, fingerprint: str) -> None: - try: - if not OneLogin_Saml2_Utils.validate_metadata_sign( - idp_metadata_xml, - validatecert=False, - fingerprint=fingerprint, - raise_exceptions=True, - fingerprintalg="sha256", - ): - raise RouteException("Failed to validate Haka metadata") - except OneLogin_Saml2_ValidationError as e: - raise RouteException(f"Failed to validate Haka metadata: {e}") - - -def init_saml_auth(req, entity_id: str, try_new_cert: bool) -> OneLogin_Saml2_Auth: - idp_metadata_xml = get_haka_metadata() - idp_data = OneLogin_Saml2_IdPMetadataParser.parse( - idp_metadata_xml, entity_id=entity_id - ) - if "idp" not in idp_data: - raise RouteException(f"IdP not found from Haka metadata: {entity_id}") - try: - do_validate_metadata(idp_metadata_xml, app.config["HAKA_METADATA_FINGERPRINT"]) - except FingerPrintException as e: - log_warning(f"{e} - trying with new fingerprint") - try: - do_validate_metadata( - idp_metadata_xml, app.config["HAKA_METADATA_FINGERPRINT_NEW"] - ) - except FingerPrintException as e: - raise RouteException(f"Failed to validate Haka metadata: {e}") - - saml_path, osett = load_sp_settings( - req["http_host"], try_new_cert, sp_validation_only=True - ) - sp = osett.get_sp_data() - - settings = OneLogin_Saml2_IdPMetadataParser.merge_settings({"sp": sp}, idp_data) - auth = OneLogin_Saml2_Auth(req, settings, custom_base_path=saml_path) - return auth - - -def get_haka_metadata() -> str: - return get_haka_metadata_from_url(app.config["HAKA_METADATA_URL"]) - - -@cache.memoize(timeout=3600 * 24) -def get_haka_metadata_from_url(url: str) -> str: - idp_metadata_xml = OneLogin_Saml2_IdPMetadataParser.get_metadata(url) - return idp_metadata_xml - - -def prepare_flask_request(r: Request): - url_data = urlparse(r.url) - return { - "https": "on", - "http_host": r.host, - "server_port": url_data.port, - "script_name": r.path, - "get_data": r.args.copy(), - "post_data": r.form.copy(), - } - - -@dataclass -class SSOData: - return_to: str - entityID: str - debug: bool = False - addUser: bool = False - - -def prepare_and_init(entity_id: str, try_new_cert: bool) -> OneLogin_Saml2_Auth: - req = prepare_flask_request(request) - auth = init_saml_auth(req, entity_id, try_new_cert) - return auth - - -REFEDS_PREFIX = "https://refeds.org/assurance" -REFEDS_LOCAL_ENTRPRISE_ASSURANCE = f"{REFEDS_PREFIX}/IAP/local-enterprise" - - -@total_ordering -class RefedsIapLevel(Enum): - """Valid Identity Assurance Proofing levels for REFEDS Assurance Framework ver 1.0 based on - https://wiki.refeds.org/display/ASS/REFEDS+Assurance+Framework+ver+1.0 - """ - - low = f"{REFEDS_PREFIX}/IAP/low" - medium = f"{REFEDS_PREFIX}/IAP/medium" - high = f"{REFEDS_PREFIX}/IAP/high" - - @staticmethod - @functools.cache - def as_list(): - return list(RefedsIapLevel) - - def __lt__(self, other: Any) -> bool: - if not isinstance(other, RefedsIapLevel): - return NotImplemented - return RefedsIapLevel.as_list().index(self) < RefedsIapLevel.as_list().index( - other - ) - - @staticmethod - def from_string(s: str) -> Optional["RefedsIapLevel"]: - try: - return RefedsIapLevel(s) - except ValueError: - return None - - -@dataclass(frozen=True) -class IdentityAssuranceProofing: - """Represents user's Identity Assurance Proofing (IAP) level. - IAP describes how the user's identity is assured (e.g. email, government ID, etc.) - - Attributes: - highest_refeds_level: Highest IAP level according to REFEDS Assurance Framework - local_enterprise: If True, user's identity proofing is good enough to access Home Organisations' - administrative systems. - """ - - highest_refeds_level: RefedsIapLevel - local_enterprise: bool - - -@dataclass -class TimRequestedAttributes: - saml_auth: OneLogin_Saml2_Auth - friendly_name_map: dict = field(init=False) - - def __post_init__(self): - self.friendly_name_map = {} - settings: OneLogin_Saml2_Settings = self.saml_auth.get_settings() - for ra in settings.get_sp_data()["attributeConsumingService"][ - "requestedAttributes" - ]: - self.friendly_name_map[ra["friendlyName"]] = ra["name"] - - def get_attribute_by_friendly_name(self, name: str) -> str | None: - values = self.get_attributes_by_friendly_name(name) - return values[0] if values else None - - def get_attributes_by_friendly_name(self, name: str) -> list[str] | None: - return self.saml_auth.get_attribute(self.friendly_name_map[name]) - - @property - def cn(self): - return self.get_attribute_by_friendly_name("cn") - - @property - def mail(self): - return self.get_attribute_by_friendly_name("mail") - - @property - def sn(self): - return self.get_attribute_by_friendly_name("sn") - - @property - def display_name(self): - return self.get_attribute_by_friendly_name("displayName") - - @property - def edu_person_principal_name(self): - return self.get_attribute_by_friendly_name("eduPersonPrincipalName") - - @property - def edu_person_assurance(self) -> list[str]: - return self.get_attributes_by_friendly_name("eduPersonAssurance") - - @property - def given_name(self): - return self.get_attribute_by_friendly_name("givenName") - - @property - def preferred_language(self): - return self.get_attribute_by_friendly_name("preferredLanguage") - - @property - def eppn_parts(self): - return self.edu_person_principal_name.split("@") - - @property - def org(self): - return self.eppn_parts[1] - - @property - def unique_codes(self) -> list[str] | None: - return self.get_attributes_by_friendly_name("schacPersonalUniqueCode") - - @property - def derived_username(self): - uname, org = self.eppn_parts - if org == app.config["HOME_ORGANIZATION"]: - return uname - return f"{org}:{uname}" - - @cached_property - def identity_assurance_proofing(self) -> IdentityAssuranceProofing: - """Parses and returns the best Identity Assurance Proofing (IAP) level - from eduPersonAssurance based on REFEDS Assurance Framework ver 1.0 spec. - - :return: Identity assurence proofing level - """ - iap_levels = [ - RefedsIapLevel.from_string(level) for level in self.edu_person_assurance - ] - # Don't check default because IAP must always be provided - best_level = max(level for level in iap_levels if level is not None) - return IdentityAssuranceProofing( - best_level, REFEDS_LOCAL_ENTRPRISE_ASSURANCE in self.edu_person_assurance - ) - - def to_json(self): - return { - "cn": self.cn, - "displayName": self.display_name, - "eduPersonPrincipalName": self.edu_person_principal_name, - "givenName": self.given_name, - "mail": self.mail, - "preferredLanguage": self.preferred_language, - "sn": self.sn, - "schacPersonalUniqueCode": self.unique_codes, - } - - -@saml.get("/sso") -@use_model(SSOData) -def sso(m: SSOData): - try: - auth = prepare_and_init(m.entityID, try_new_cert=True) - except OneLogin_Saml2_Error: - auth = prepare_and_init(m.entityID, try_new_cert=False) - session["entityID"] = m.entityID - login_url = auth.login(return_to=m.return_to) - session["requestID"] = auth.get_last_request_id() - if not logged_in() and m.addUser: - raise AccessDenied("You must be logged in before adding users to session.") - session["adding_user"] = m.addUser - if m.debug: - session["debugSSO"] = True - else: - session.pop("debugSSO", None) - return redirect(login_url) - - -@csrf.exempt -@saml.post("/acs") -def acs(): - entity_id = session.get("entityID") - if not entity_id: - raise RouteException("entityID not in session") - try: - auth = try_process_saml_response(entity_id, try_new_cert=True) - except (SamlProcessingError, OneLogin_Saml2_Error) as e: - # OneLogin_Saml2_Error happens if there is no new certificate in the file system. That means there is no - # rollover going on, so nothing interesting is happening. - # - # If instead we get a SamlProcessingError, that means there is a new certificate, but the IdP encrypted - # the SAML response with the old certificate, so we should account for that. - if isinstance(e, SamlProcessingError): - log_warning( - f"Failed to process SAML response with the new certificate; trying with old." - ) - try: - auth = try_process_saml_response(entity_id, try_new_cert=False) - except SamlProcessingError as e: - raise RouteException(str(e)) - errors = auth.get_errors() - if not auth.is_authenticated(): - err = f"Authentication failed: {auth.get_last_error_reason()}" - log_warning(err) - raise RouteException( - f'{err} (Please contact {app.config["HELP_EMAIL"]} if the problem persists.)' - ) - if errors: - err = str(errors) - log_warning(err) - raise RouteException(err) - session.pop("requestID", None) - timattrs = TimRequestedAttributes(auth) - org_group = UserGroup.get_organization_group(timattrs.org) - parsed_codes = [] - ucs = timattrs.unique_codes - if ucs: - for c in ucs: - parsed = SchacPersonalUniqueCode.parse(c) - if not parsed: - log_warning(f"Failed to parse unique code: {c}") - else: - parsed_codes.append(parsed) - elif ucs is None: - log_warning(f"{timattrs.derived_username} did not receive unique codes") - else: - log_warning(f"{timattrs.derived_username} received empty unique code list") - # Don't update email here to prevent setting is as primary automatically - user = create_or_update_user( - UserInfo( - username=timattrs.derived_username, - full_name=f"{timattrs.sn} {timattrs.given_name}", - email=timattrs.mail, - given_name=timattrs.given_name, - last_name=timattrs.sn, - origin=UserOrigin.Haka, - unique_codes=parsed_codes, - ), - group_to_add=org_group, - update_email=False, # Don't update the email here since we don't want to force the Haka mail as primary - ) - user.set_emails([timattrs.mail], ContactOrigin.Haka, can_update_primary=True) - haka = UserGroup.get_haka_group() - if haka not in user.groups: - user.groups.append(haka) - db.session.commit() - set_user_to_session(user) - if session.get("debugSSO"): - return json_response(auth.get_attributes()) - rs = request.form.get("RelayState") - if rs: - return redirect(auth.redirect_to(rs)) - return redirect(url_for("start_page")) - - -class SamlProcessingError(Exception): - pass - - -def try_process_saml_response(entity_id: str, try_new_cert: bool): - auth = prepare_and_init(entity_id, try_new_cert) - request_id = session.get("requestID") - if not request_id: - err = "requestID missing from session" - log_warning(err) - raise RouteException(err) - try: - auth.process_response(request_id=request_id) - except Exception as e: - err = f"Error processing SAML response: {str(e)}" - log_warning(err) - raise SamlProcessingError(err) - return auth - - -@saml.get("") -def get_metadata(): - _, settings = load_sp_settings(request.host, sp_validation_only=True) - metadata = settings.get_sp_metadata() - errors = settings.validate_metadata(metadata) - - if len(errors) == 0: - resp = make_response(metadata, 200) - resp.headers["Content-Type"] = "text/xml" - else: - resp = make_response(", ".join(errors), 400) - return resp - - -@saml.get("/feed") -def get_idps(): - idp_metadata_xml = get_haka_metadata() - root = etree.fromstring(idp_metadata_xml) - nsmap = copy(root.nsmap) - rootns = nsmap.pop(None) - nsmap["xhtml"] = rootns - select_idps = CSSSelector( - "xhtml|IDPSSODescriptor", - namespaces=nsmap, - ) - select_displaynames = CSSSelector( - "mdui|DisplayName", - namespaces=nsmap, - ) - feed = [] - for idp in select_idps(root): - names = [] - for n in select_displaynames(idp): - names.append( - { - "value": n.text, - "lang": n.attrib["{http://www.w3.org/XML/1998/namespace}lang"], - } - ) - scopes = [] - nsmap = idp.nsmap - nsmap.pop(None) - for n in CSSSelector( - "shibmd|Scope", - namespaces=nsmap, - )(idp): - scopes.append(n.text) - feed.append( - { - "entityID": idp.getparent().attrib["entityID"], - "displayNames": names, - "scopes": scopes, - } - ) - return json_response(feed) diff --git a/timApp/auth/saml/__init__.py b/timApp/auth/saml/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/timApp/auth/saml/attributemaps/haka.py b/timApp/auth/saml/attributemaps/haka.py new file mode 100644 index 0000000000..f9993b8d3e --- /dev/null +++ b/timApp/auth/saml/attributemaps/haka.py @@ -0,0 +1,16 @@ +from saml2.saml import NAME_FORMAT_URI + +MAP = { + "identifier": NAME_FORMAT_URI, + "fro": { + "urn:oid:2.5.4.3": "cn", + "urn:oid:2.16.840.1.113730.3.1.241": "displayName", + "urn:oid:1.3.6.1.4.1.5923.1.1.1.11": "eduPersonAssurance", + "urn:oid:1.3.6.1.4.1.5923.1.1.1.6": "eduPersonPrincipalName", + "urn:oid:2.5.4.42": "givenName", + "urn:oid:0.9.2342.19200300.100.1.3": "mail", + "urn:oid:2.16.840.1.113730.3.1.39": "preferredLanguage", + "urn:oid:2.5.4.4": "sn", + "urn:oid:1.3.6.1.4.1.25178.1.2.14": "schacPersonalUniqueCode", + }, +} diff --git a/timApp/auth/saml/attributes.py b/timApp/auth/saml/attributes.py new file mode 100644 index 0000000000..24f501c78d --- /dev/null +++ b/timApp/auth/saml/attributes.py @@ -0,0 +1,108 @@ +import functools +from dataclasses import dataclass +from typing import Any, Optional + +from timApp.auth.saml.identity_assurance import ( + IdentityAssuranceProofing, + RefedsIapLevel, + REFEDS_LOCAL_ENTRPRISE_ASSURANCE, + _T, +) +from timApp.tim_app import app +from timApp.util.flask.requesthelper import RouteException + + +@dataclass +class SAMLUserAttributes: + response_attribues: dict[str, Any] + + def _get_attribute_safe(self, name: str) -> Optional[_T]: + return self.response_attribues.get(name, [None])[0] + + def _get_attribute(self, name: str) -> _T: + value = self._get_attribute_safe(name) + if value is None: + raise RouteException(f"Missing required attribute {name}") + return value + + @property + def common_name(self) -> str: + return self._get_attribute("cn") + + @property + def email(self) -> str: + return self._get_attribute("mail") + + @property + def surname(self) -> str: + return self._get_attribute("sn") + + @property + def given_name(self) -> str: + return self._get_attribute("givenName") + + @property + def display_name(self) -> str: + return self._get_attribute("displayName") + + @property + def edu_person_principal_name(self) -> str: + return self._get_attribute("eduPersonPrincipalName") + + @property + def edu_person_assurance(self) -> list[str]: + res = self.response_attribues.get("eduPersonAssurance") + if not isinstance(res, list): + raise RouteException("eduPersonAssurance is not a list") + return res + + @property + def preferred_language(self) -> str: + return self._get_attribute("preferredLanguage") + + @property + def eppn_parts(self) -> list[str]: + return self.edu_person_principal_name.split("@") + + @property + def org(self) -> str: + return self.eppn_parts[1] + + @property + def unique_codes(self) -> list[str] | None: + return self.response_attribues.get("schacPersonalUniqueCode") + + @property + def derived_username(self) -> str: + uname, org = self.eppn_parts + if org == app.config["HOME_ORGANIZATION"]: + return uname + return f"{org}:{uname}" + + @functools.cached_property + def identity_assurance_proofing(self) -> IdentityAssuranceProofing: + """Parses and returns the best Identity Assurance Proofing (IAP) level + from eduPersonAssurance based on REFEDS Assurance Framework ver 1.0 spec. + + :return: Identity assurence proofing level + """ + iap_levels = [ + RefedsIapLevel.from_string(level) for level in self.edu_person_assurance + ] + # Don't check default because IAP must always be provided + best_level = max(level for level in iap_levels if level is not None) + return IdentityAssuranceProofing( + best_level, REFEDS_LOCAL_ENTRPRISE_ASSURANCE in self.edu_person_assurance + ) + + def to_json(self) -> dict[str, Any]: + return { + "cn": self.common_name, + "displayName": self.display_name, + "eduPersonPrincipalName": self.edu_person_principal_name, + "givenName": self.given_name, + "mail": self.email, + "preferredLanguage": self.preferred_language, + "sn": self.surname, + "schacPersonalUniqueCode": self.unique_codes, + } diff --git a/timApp/auth/saml/client.py b/timApp/auth/saml/client.py new file mode 100644 index 0000000000..fe8c2db544 --- /dev/null +++ b/timApp/auth/saml/client.py @@ -0,0 +1,111 @@ +import itertools +from pathlib import Path +from typing import Callable + +from saml2.client import Saml2Client +from saml2.config import Config as Saml2Config +from saml2.mdstore import MetadataStore, MetaDataLoader +from saml2.sigver import SignatureError + +from timApp.tim_app import app +from timApp.util.error_handlers import report_error +from timApp.util.flask.requesthelper import RouteException +from timApp.util.logger import log_warning + +# Add an external loader that allows checking for certs +_metadata_load = MetadataStore.load + + +def _metadatastore_load(self: MetadataStore, *args: list, **kwargs: dict) -> None: + typ = args[0] + + if typ == "loadex": + key = kwargs["loader"] + md = MetaDataLoader( + self.attrc, + kwargs["loader"], + cert=kwargs.get("cert"), + security=self.security, + filename="metadata.xml", + ) + md.load() + self.metadata[key] = md + else: + _metadata_load(self, *args, **kwargs) + + +MetadataStore.load = _metadatastore_load + + +def get_saml_config(metadata_loader: Callable[[], bytes]) -> Saml2Config: + def _do_get_saml_config(try_new_cert: bool, try_new_metadata: bool) -> Saml2Config: + saml_path = Path(app.config["SAML_PATH"]) + if try_new_cert: + saml_path /= "new" + + config_file = saml_path / "config.py" + saml2_config = Saml2Config() + + # We load the config file manually so that we can fill it with the extra info + try: + globals_dict = globals().copy() + locals_dict: dict = {} + exec(config_file.read_text(), globals_dict, locals_dict) + saml2_config.load_file(str(config_file)) + config_dict = locals_dict["CONFIG"] + except FileNotFoundError: + raise FileNotFoundError(f"Could not find SAML config file.") + except KeyError: + raise KeyError(f"Could not find CONFIG dict in SAML config file.") + + metadata_file_name = "metadata_new.crt" if try_new_metadata else "metadata.crt" + + config_dict["key_file"] = str(saml_path / "certs" / "sp.key") + config_dict["cert_file"] = str(saml_path / "certs" / "sp.crt") + # Encryption keypairs seem to be a different option, but e.g., in HAKA the same keys are used for encrypting + # requests and decrypting responses + config_dict["encryption_keypairs"] = [ + { + "key_file": str(saml_path / "certs" / "sp.key"), + "cert_file": str(saml_path / "certs" / "sp.crt"), + } + ] + config_dict["metadata"] = { + "loadex": [ + { + "loader": metadata_loader, + "cert": str(saml_path / "certs" / metadata_file_name), + } + ] + } + config_dict["attribute_map_dir"] = str(saml_path.parent / "attributemaps") + config_dict["allow_unknown_attributes"] = True + saml2_config.load(config_dict) + + return saml2_config + + errors = [] + for new_cert, new_meta in itertools.product((False, True), (False, True)): + try: + return _do_get_saml_config(try_new_cert=new_cert, try_new_metadata=new_meta) + except FileNotFoundError as e: + err = f"SAML (new_cert={new_cert}, new_meta={new_meta}): Could not load SAML config: {e}" + log_warning(err) + errors.append(err) + except SignatureError as e: + err = f"SAML (new_cert={new_cert}, new_meta={new_meta}): Could not load SAML config: {e}" + log_warning(err) + errors.append(err) + + report_error( + "Failed to validate SAML metadata signature. SAML login (HAKA) is not available.\n\n" + f"Errors:\n" + "\n".join(f"* {e}" for e in errors) + ) + raise RouteException( + "Failed to validate SAML metadata signature. Administrators have been notified." + ) + + +def get_saml_client(metadata_loader: Callable[[], bytes]) -> Saml2Client: + saml2_client = Saml2Client(config=get_saml_config(metadata_loader)) + return saml2_client diff --git a/timApp/auth/saml/dev/config.py b/timApp/auth/saml/dev/config.py new file mode 100644 index 0000000000..8ea84722ca --- /dev/null +++ b/timApp/auth/saml/dev/config.py @@ -0,0 +1,36 @@ +from saml2 import BINDING_HTTP_POST +from saml2.saml import NAMEID_FORMAT_PERSISTENT + +from timApp.util.flask.requesthelper import get_active_host_url + +CONFIG = { + "entityid": "https://timdevs02.it.jyu.fi/saml", + "name": "Jyvaskylan yliopiston TIM (testipalvelin)", + "description": "", + "service": { + "sp": { + "endpoints": { + "assertion_consumer_service": [ + (f"{get_active_host_url()}saml/acs", BINDING_HTTP_POST), + ], + }, + "required_attributes": [ + "cn", + "displayName", + "eduPersonAssurance", + "eduPersonPrincipalName", + "givenName", + "mail", + "preferredLanguage", + "sn", + ], + "optional_attributes": [ + "schacPersonalUniqueCode", + ], + "want_response_signed": False, + "want_assertions_signed": False, + "want_assertions_or_response_signed": True, + "name_id_format": NAMEID_FORMAT_PERSISTENT, + }, + }, +} diff --git a/timApp/auth/saml/dev/settings.json b/timApp/auth/saml/dev/settings.json deleted file mode 100644 index 253fde2b04..0000000000 --- a/timApp/auth/saml/dev/settings.json +++ /dev/null @@ -1,57 +0,0 @@ -{ - "strict": true, - "sp": { - "entityId": "https://timdevs02-5.it.jyu.fi/saml", - "assertionConsumerService": { - "url": "https://timdevs02-5.it.jyu.fi/saml/acs", - "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" - }, - "attributeConsumingService": { - "serviceName": "Jyvaskylan yliopiston TIM (testipalvelin)", - "serviceDescription": "", - "requestedAttributes": [ - { - "name": "urn:oid:2.5.4.3", - "isRequired": true, - "friendlyName": "cn" - }, - { - "name": "urn:oid:2.16.840.1.113730.3.1.241", - "isRequired": true, - "friendlyName": "displayName" - }, - { - "name": "urn:oid:1.3.6.1.4.1.5923.1.1.1.6", - "isRequired": true, - "friendlyName": "eduPersonPrincipalName" - }, - { - "name": "urn:oid:2.5.4.42", - "isRequired": true, - "friendlyName": "givenName" - }, - { - "name": "urn:oid:0.9.2342.19200300.100.1.3", - "isRequired": true, - "friendlyName": "mail" - }, - { - "name": "urn:oid:2.16.840.1.113730.3.1.39", - "isRequired": true, - "friendlyName": "preferredLanguage" - }, - { - "name": "urn:oid:2.5.4.4", - "isRequired": true, - "friendlyName": "sn" - }, - { - "name": "urn:oid:1.3.6.1.4.1.25178.1.2.14", - "isRequired": false, - "friendlyName": "schacPersonalUniqueCode" - } - ] - }, - "NameIDFormat": "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent" - } -} diff --git a/timApp/auth/saml/identity_assurance.py b/timApp/auth/saml/identity_assurance.py new file mode 100644 index 0000000000..ab51d8ade4 --- /dev/null +++ b/timApp/auth/saml/identity_assurance.py @@ -0,0 +1,55 @@ +import functools +from dataclasses import dataclass +from enum import Enum +from typing import Any, Optional, TypeVar + +REFEDS_PREFIX = "https://refeds.org/assurance" +REFEDS_LOCAL_ENTRPRISE_ASSURANCE = f"{REFEDS_PREFIX}/IAP/local-enterprise" + + +@functools.total_ordering +class RefedsIapLevel(Enum): + """Valid Identity Assurance Proofing levels for REFEDS Assurance Framework ver 1.0 based on + https://wiki.refeds.org/display/ASS/REFEDS+Assurance+Framework+ver+1.0 + """ + + low = f"{REFEDS_PREFIX}/IAP/low" + medium = f"{REFEDS_PREFIX}/IAP/medium" + high = f"{REFEDS_PREFIX}/IAP/high" + + @staticmethod + @functools.cache + def as_list() -> list["RefedsIapLevel"]: + return list(RefedsIapLevel) + + def __lt__(self, other: Any) -> bool: + if not isinstance(other, RefedsIapLevel): + return NotImplemented + return RefedsIapLevel.as_list().index(self) < RefedsIapLevel.as_list().index( + other + ) + + @staticmethod + def from_string(s: str) -> Optional["RefedsIapLevel"]: + try: + return RefedsIapLevel(s) + except ValueError: + return None + + +@dataclass(frozen=True) +class IdentityAssuranceProofing: + """Represents user's Identity Assurance Proofing (IAP) level. + IAP describes how the user's identity is assured (e.g. email, government ID, etc.) + + Attributes: + highest_refeds_level: Highest IAP level according to REFEDS Assurance Framework + local_enterprise: If True, user's identity proofing is good enough to access Home Organisations' + administrative systems. + """ + + highest_refeds_level: RefedsIapLevel + local_enterprise: bool + + +_T = TypeVar("_T") diff --git a/timApp/auth/saml/prod/readme.md b/timApp/auth/saml/prod/readme.md index 9e71a1442e..f2668bce46 100644 --- a/timApp/auth/saml/prod/readme.md +++ b/timApp/auth/saml/prod/readme.md @@ -1 +1,3 @@ -For production, add "certs" folder and "settings.json" file to this folder like in "dev" folder. +For production, add "certs" folder and "config.py" file to this folder like in "dev" folder. + +See for more information about config.py file. diff --git a/timApp/auth/saml/routes.py b/timApp/auth/saml/routes.py new file mode 100644 index 0000000000..edb7cff347 --- /dev/null +++ b/timApp/auth/saml/routes.py @@ -0,0 +1,220 @@ +from dataclasses import field + +import requests +from flask import make_response, session, request, url_for, redirect +from saml2 import SAMLError, BINDING_HTTP_POST +from saml2.client import Saml2Client +from saml2.config import Config as Saml2Config +from saml2.mdstore import MetadataStore +from saml2.metadata import create_metadata_string +from saml2.s_utils import SamlException +from werkzeug.sansio.response import Response + +from timApp.auth.accesshelper import AccessDenied +from timApp.auth.login import create_or_update_user, set_user_to_session +from timApp.auth.saml.attributes import SAMLUserAttributes +from timApp.auth.saml.client import ( + get_saml_config, + get_saml_client, +) +from timApp.auth.sessioninfo import logged_in +from timApp.tim_app import app, csrf +from timApp.timdb.sqa import db +from timApp.user.personaluniquecode import SchacPersonalUniqueCode +from timApp.user.user import UserInfo, UserOrigin +from timApp.user.usercontact import ContactOrigin +from timApp.user.usergroup import UserGroup +from timApp.util.error_handlers import report_error +from timApp.util.flask.cache import cache +from timApp.util.flask.requesthelper import RouteException +from timApp.util.flask.responsehelper import json_response +from timApp.util.flask.typedblueprint import TypedBlueprint +from timApp.util.logger import log_warning + +saml = TypedBlueprint("saml", __name__, url_prefix="/saml") + + +@cache.memoize(timeout=3600 * 24) +def _get_idp_metadata_from_url(url: str) -> bytes: + try: + response = requests.get(url) + response.raise_for_status() + return response.content + except requests.exceptions.RequestException as e: + raise RouteException(f"Could not fetch IDP metadata from {url}: {e}") + + +def _get_haka_metadata() -> bytes: + return _get_idp_metadata_from_url(app.config["HAKA_METADATA_URL"]) + + +def _get_saml_client() -> Saml2Client: + return get_saml_client(_get_haka_metadata) + + +def _get_saml_config() -> Saml2Config: + return get_saml_config(_get_haka_metadata) + + +@saml.get("/sso") +def sso( + return_to: str, + entity_id: str = field(metadata={"data_key": "entityID"}), + debug: bool = False, + add_user: bool = field(default=False, metadata={"data_key": "addUser"}), +) -> Response: + if not logged_in() and add_user: + raise AccessDenied("You must be logged in before adding users to session.") + + client = _get_saml_client() + req_id, info = client.prepare_for_authenticate(entity_id, relay_state=return_to) + redirect_url = next(v for k, v in info["headers"] if k == "Location") + + session["entityID"] = entity_id + session["adding_user"] = add_user + session["requestID"] = req_id + session["cameFrom"] = request.base_url + if debug: + session["debugSSO"] = True + else: + session.pop("debugSSO", None) + return redirect(redirect_url) + + +@csrf.exempt +@saml.post("/acs") +def acs() -> Response: + entity_id = session.get("entityID") + came_from = session.get("cameFrom") + request_id = session.get("requestID") + if not entity_id: + raise RouteException("No entityID in session.") + if not came_from: + raise RouteException("No cameFrom in session.") + if not request_id: + raise RouteException("No requestID in session.") + + client = _get_saml_client() + + try: + resp = client.parse_authn_request_response( + request.form["SAMLResponse"], + BINDING_HTTP_POST, + outstanding={request_id: came_from}, + ) + except SamlException as e: + report_error(f"Error parsing SAML response: {e}", with_http_body=True) + raise RouteException( + f"Error parsing SAML response. You can log in using your TIM username and password instead. " + f"Please contact {app.config['HELP_EMAIL']} if the problem persists." + ) + ava = resp.get_identity() + + session.pop("requestID", None) + session.pop("cameFrom", None) + + saml_attributes = SAMLUserAttributes(ava) + org_group = UserGroup.get_organization_group(saml_attributes.org) + parsed_codes = [] + ucs = saml_attributes.unique_codes + if ucs: + for c in ucs: + parsed = SchacPersonalUniqueCode.parse(c) + if not parsed: + log_warning(f"Failed to parse unique code: {c}") + else: + parsed_codes.append(parsed) + elif ucs is None: + log_warning(f"{saml_attributes.derived_username} did not receive unique codes") + else: + log_warning( + f"{saml_attributes.derived_username} received empty unique code list" + ) + # Don't update email here to prevent setting is as primary automatically + user = create_or_update_user( + UserInfo( + username=saml_attributes.derived_username, + full_name=f"{saml_attributes.surname} {saml_attributes.given_name}", + email=saml_attributes.email, + given_name=saml_attributes.given_name, + last_name=saml_attributes.surname, + origin=UserOrigin.Haka, + unique_codes=parsed_codes, + ), + group_to_add=org_group, + update_email=False, # Don't update the email here since we don't want to force the Haka mail as primary + ) + user.set_emails( + [saml_attributes.email], ContactOrigin.Haka, can_update_primary=True + ) + haka = UserGroup.get_haka_group() + if haka not in user.groups: + user.groups.append(haka) + db.session.commit() + set_user_to_session(user) + if session.get("debugSSO"): + return json_response(ava) + rs = request.form.get("RelayState") + if rs: + return redirect(rs) + return redirect(url_for("start_page")) + + +@saml.get("") +def get_metadata() -> Response: + saml_config = _get_saml_config() + try: + resp = make_response(create_metadata_string(None, config=saml_config), 200) + resp.headers["Content-Type"] = "text/xml" + except SAMLError as e: + log_warning(f"Could not create SAML metadata: {e}") + resp = make_response(f"Could not create SAML metadata: {e}", 400) + + return resp + + +@saml.get("/feed") +def get_idps() -> Response: + config = _get_saml_config() + meta: MetadataStore = config.metadata + idps = meta.with_descriptor("idpsso") + feed = [] + for entity_id, idp_info in idps.items(): + sso_desc = idp_info.get("idpsso_descriptor") + if not sso_desc: + log_warning(f"SAML: Could not find SSO info for {entity_id}") + continue + ext_elems = sso_desc[0].get("extensions", {}).get("extension_elements", None) + if ext_elems is None: + log_warning(f"SAML: Could not find extension elements for {entity_id}") + continue + ui_info = next( + (d for d in ext_elems if d["__class__"].endswith("ui&UIInfo")), None + ) + assert isinstance(ui_info, dict) + if ui_info is None: + log_warning(f"SAML: Could not find UIInfo for {entity_id}") + continue + # get display names + display_names = [ + { + "value": name_entry["text"], + "lang": name_entry["lang"], + } + for name_entry in ui_info["display_name"] + ] + # Check for scope extension + scopes = [] + scope_elems = [e for e in ext_elems if e["__class__"].endswith("&Scope")] + for ext_elem in scope_elems: + scopes.append(ext_elem["text"]) + + feed.append( + { + "entityID": entity_id, + "displayNames": display_names, + "scopes": scopes, + } + ) + + return json_response(feed) diff --git a/timApp/auth/saml/test/config.py b/timApp/auth/saml/test/config.py new file mode 100644 index 0000000000..8ea84722ca --- /dev/null +++ b/timApp/auth/saml/test/config.py @@ -0,0 +1,36 @@ +from saml2 import BINDING_HTTP_POST +from saml2.saml import NAMEID_FORMAT_PERSISTENT + +from timApp.util.flask.requesthelper import get_active_host_url + +CONFIG = { + "entityid": "https://timdevs02.it.jyu.fi/saml", + "name": "Jyvaskylan yliopiston TIM (testipalvelin)", + "description": "", + "service": { + "sp": { + "endpoints": { + "assertion_consumer_service": [ + (f"{get_active_host_url()}saml/acs", BINDING_HTTP_POST), + ], + }, + "required_attributes": [ + "cn", + "displayName", + "eduPersonAssurance", + "eduPersonPrincipalName", + "givenName", + "mail", + "preferredLanguage", + "sn", + ], + "optional_attributes": [ + "schacPersonalUniqueCode", + ], + "want_response_signed": False, + "want_assertions_signed": False, + "want_assertions_or_response_signed": True, + "name_id_format": NAMEID_FORMAT_PERSISTENT, + }, + }, +} diff --git a/timApp/auth/saml/test/settings.json b/timApp/auth/saml/test/settings.json deleted file mode 100644 index 253fde2b04..0000000000 --- a/timApp/auth/saml/test/settings.json +++ /dev/null @@ -1,57 +0,0 @@ -{ - "strict": true, - "sp": { - "entityId": "https://timdevs02-5.it.jyu.fi/saml", - "assertionConsumerService": { - "url": "https://timdevs02-5.it.jyu.fi/saml/acs", - "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" - }, - "attributeConsumingService": { - "serviceName": "Jyvaskylan yliopiston TIM (testipalvelin)", - "serviceDescription": "", - "requestedAttributes": [ - { - "name": "urn:oid:2.5.4.3", - "isRequired": true, - "friendlyName": "cn" - }, - { - "name": "urn:oid:2.16.840.1.113730.3.1.241", - "isRequired": true, - "friendlyName": "displayName" - }, - { - "name": "urn:oid:1.3.6.1.4.1.5923.1.1.1.6", - "isRequired": true, - "friendlyName": "eduPersonPrincipalName" - }, - { - "name": "urn:oid:2.5.4.42", - "isRequired": true, - "friendlyName": "givenName" - }, - { - "name": "urn:oid:0.9.2342.19200300.100.1.3", - "isRequired": true, - "friendlyName": "mail" - }, - { - "name": "urn:oid:2.16.840.1.113730.3.1.39", - "isRequired": true, - "friendlyName": "preferredLanguage" - }, - { - "name": "urn:oid:2.5.4.4", - "isRequired": true, - "friendlyName": "sn" - }, - { - "name": "urn:oid:1.3.6.1.4.1.25178.1.2.14", - "isRequired": false, - "friendlyName": "schacPersonalUniqueCode" - } - ] - }, - "NameIDFormat": "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent" - } -} diff --git a/timApp/defaultconfig.py b/timApp/defaultconfig.py index 07a64a76e1..c57d872e92 100644 --- a/timApp/defaultconfig.py +++ b/timApp/defaultconfig.py @@ -159,21 +159,12 @@ SISU_CERT_PATH = "/service/certs/sisu.pem" SAML_PATH = "/service/timApp/auth/saml/dev" +SAML_VERIFY_METADATA = False HAKA_METADATA_URL = "https://haka.funet.fi/metadata/haka_test_metadata_signed.xml" -HAKA_METADATA_FINGERPRINT = ( - "811dd04e5bde0976be6c7aa6a62e2e633d3de37807642e6c532019674545d019" -) # In production, copy these to prodconfig.py and remove the "_PROD" suffix. SAML_PATH_PROD = "/service/timApp/auth/saml/prod" HAKA_METADATA_URL_PROD = "https://haka.funet.fi/metadata/haka-metadata.xml" -HAKA_METADATA_FINGERPRINT_PROD = ( - "70a9058262190cc23f8b0b14d6f0b7c0c74648e8b979bf4258eb7e23674a52f8" -) -# Fingerprint for the upcoming (1.12.2020) v5 certificate. -HAKA_METADATA_FINGERPRINT_NEW_PROD = ( - "a2c1eff331849cbfbfc920924861e03c8a56414ec003bf919e7f1b1a7dbc3169" -) HOME_ORGANIZATION = "jyu.fi" diff --git a/timApp/tim.py b/timApp/tim.py index 4172d88fe2..747834b4a5 100755 --- a/timApp/tim.py +++ b/timApp/tim.py @@ -22,7 +22,7 @@ from timApp.auth.access.routes import access from timApp.auth.login import login_page from timApp.auth.oauth2.oauth2 import init_oauth -from timApp.auth.saml import saml +from timApp.auth.saml.routes import saml from timApp.auth.session.routes import user_sessions from timApp.auth.sessioninfo import ( get_current_user_object, diff --git a/timApp/util/flask/requesthelper.py b/timApp/util/flask/requesthelper.py index 6a8194496f..da2a7bc11d 100644 --- a/timApp/util/flask/requesthelper.py +++ b/timApp/util/flask/requesthelper.py @@ -9,7 +9,7 @@ from urllib.parse import urlparse import requests -from flask import Request, current_app, g, Response +from flask import Request, current_app, g, Response, has_request_context from flask import request from marshmallow import ValidationError, Schema from webargs.flaskparser import use_args @@ -105,6 +105,13 @@ def is_localhost() -> bool: return current_app.config["TIM_HOST"] in ("http://localhost", "http://caddy") +def get_active_host_url() -> str: + # check if inside request context + if has_request_context(): + return request.host_url + return f"{current_app.config['TIM_HOST']}/" + + def get_consent_opt() -> Consent | None: consent_opt = get_option(request, "consent", "any") if consent_opt == "true":